Upload 20 files
Browse files- .dockerignore +15 -0
- .env.example +18 -0
- .gitignore +9 -0
- Dockerfile +25 -0
- Makefile +19 -0
- README.md +389 -0
- TESTING.md +76 -0
- app.py +919 -0
- compose.yaml +17 -0
- live_zcode_test.py +129 -0
- pytest.ini +4 -0
- requirements-dev.txt +3 -0
- requirements.txt +4 -0
- smoke_test.py +151 -0
- tests/test_app.py +379 -0
- tests/test_tool_protocol.py +286 -0
- tests/test_upstage_provider.py +865 -0
- tool_protocol.py +645 -0
- upstage_interactive.py +403 -0
- upstage_provider.py +1374 -0
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.venv
|
| 4 |
+
__pycache__
|
| 5 |
+
.pytest_cache
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.log
|
| 8 |
+
.env
|
| 9 |
+
upstage_creds.json
|
| 10 |
+
tests
|
| 11 |
+
smoke_test.py
|
| 12 |
+
live_zcode_test.py
|
| 13 |
+
requirements-dev.txt
|
| 14 |
+
compose.yaml
|
| 15 |
+
Makefile
|
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Public Hugging Face Space mode (requested default)
|
| 2 |
+
PUBLIC_ACCESS=true
|
| 3 |
+
# Only used if PUBLIC_ACCESS=false
|
| 4 |
+
PROVIDER_API_KEY=
|
| 5 |
+
|
| 6 |
+
PORT=7860
|
| 7 |
+
LOG_LEVEL=INFO
|
| 8 |
+
UPSTAGE_CACHE_DIR=/tmp/.cache/upstage
|
| 9 |
+
MAX_CONCURRENT_REQUESTS=6
|
| 10 |
+
QUEUE_TIMEOUT_SECONDS=20
|
| 11 |
+
UPSTREAM_TIMEOUT_SECONDS=330
|
| 12 |
+
UPSTREAM_RETRIES=1
|
| 13 |
+
TOOL_PROTOCOL_RETRIES=1
|
| 14 |
+
MAX_BODY_MB=4
|
| 15 |
+
MAX_INPUT_CHARS=1000000
|
| 16 |
+
MAX_BUFFERED_OUTPUT_CHARS=8000000
|
| 17 |
+
# Disabled by default so ZCode agent loops are not throttled. Set for public abuse control.
|
| 18 |
+
RATE_LIMIT_PER_MINUTE=0
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.ruff_cache/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.log
|
| 8 |
+
upstage_creds.json
|
| 9 |
+
.cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim-bookworm
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PORT=7860 \
|
| 7 |
+
UPSTAGE_CACHE_DIR=/home/user/.cache/upstage
|
| 8 |
+
|
| 9 |
+
RUN useradd --create-home --uid 1000 --shell /bin/bash user
|
| 10 |
+
WORKDIR /home/user/app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user:user requirements.txt ./requirements.txt
|
| 13 |
+
RUN python -m pip install --upgrade pip && \
|
| 14 |
+
python -m pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
COPY --chown=user:user app.py tool_protocol.py upstage_provider.py upstage_interactive.py ./
|
| 17 |
+
RUN mkdir -p /home/user/.cache/upstage && chown -R user:user /home/user
|
| 18 |
+
|
| 19 |
+
USER user
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 23 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/healthz', timeout=3)" || exit 1
|
| 24 |
+
|
| 25 |
+
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1 --proxy-headers --forwarded-allow-ips='*'"]
|
Makefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: install test live run smoke zcode-live
|
| 2 |
+
|
| 3 |
+
install:
|
| 4 |
+
python -m pip install -r requirements-dev.txt
|
| 5 |
+
|
| 6 |
+
test:
|
| 7 |
+
pytest -q
|
| 8 |
+
|
| 9 |
+
live:
|
| 10 |
+
UPSTAGE_LIVE=1 pytest -q tests/test_upstage_provider.py -k Live
|
| 11 |
+
|
| 12 |
+
run:
|
| 13 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 14 |
+
|
| 15 |
+
smoke:
|
| 16 |
+
python smoke_test.py http://127.0.0.1:7860
|
| 17 |
+
|
| 18 |
+
zcode-live:
|
| 19 |
+
python live_zcode_test.py http://127.0.0.1:7860
|
README.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Upstage Solar for ZCode
|
| 3 |
+
emoji: ☀️
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Upstage Solar for ZCode
|
| 12 |
+
|
| 13 |
+
A purpose-built **OpenAI Chat Completions provider** for **Z.ai ZCode**, packaged as a public Hugging Face Docker Space.
|
| 14 |
+
|
| 15 |
+
It is based on the async, pure-HTTP provider from:
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
Adarshukumar/serv__/New Upstage Change Logs/upstage_provider.py
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
The transport discovers an anonymous Upstage Console session through Next.js server actions, uses `curl_cffi` with browser TLS impersonation, and talks to the Upstage demo Chat Completions stream. No browser, Selenium, API key, or upstream account is required.
|
| 22 |
+
|
| 23 |
+
> This is an independent compatibility gateway, not an official Upstage service. The anonymous Console transport can change or be rate-limited by Upstage. Review Upstage's terms before operating a public relay.
|
| 24 |
+
|
| 25 |
+
## Why this version works with ZCode
|
| 26 |
+
|
| 27 |
+
The source provider was an excellent async chat library, but it was not an OpenAI web server and did not implement function calls. This project adds:
|
| 28 |
+
|
| 29 |
+
- `GET /v1/models`
|
| 30 |
+
- `GET /v1/models/{model}`
|
| 31 |
+
- `POST /v1/chat/completions`
|
| 32 |
+
- non-streaming OpenAI responses
|
| 33 |
+
- realtime SSE for ordinary chat
|
| 34 |
+
- `[DONE]` and `stream_options.include_usage`
|
| 35 |
+
- separate `reasoning_content`
|
| 36 |
+
- optional Upstage/Tavily web search and returned sources
|
| 37 |
+
- OpenAI `system`, `developer`, `user`, `assistant`, and `tool` message handling
|
| 38 |
+
- OpenAI `tools`, `tool_choice`, `parallel_tool_calls`, assistant `tool_calls`, and tool-result loops
|
| 39 |
+
- JSON-Schema validation of generated function arguments
|
| 40 |
+
- one bounded corrective retry when Solar emits malformed or missing required calls
|
| 41 |
+
- OpenAI-shaped errors, body/input limits, concurrency control, optional rate limiting, and optional Bearer authentication
|
| 42 |
+
- a non-root UID 1000 Docker image for Hugging Face Spaces
|
| 43 |
+
|
| 44 |
+
## Important tool-calling design
|
| 45 |
+
|
| 46 |
+
Upstage's official API supports native function calling. However, the anonymous Console demo endpoint used here currently accepts but **ignores** native `tools` and `tool_choice` fields. Pretending otherwise would break ZCode.
|
| 47 |
+
|
| 48 |
+
This gateway therefore uses a constrained adapter:
|
| 49 |
+
|
| 50 |
+
1. ZCode's function catalog and tool choice are validated.
|
| 51 |
+
2. A bounded tool protocol is injected into Solar's system prompt.
|
| 52 |
+
3. Solar emits its learned internal call format.
|
| 53 |
+
4. The gateway parses the call, checks the function name, parses arguments as JSON, and validates them against the requested function's JSON Schema.
|
| 54 |
+
5. Valid calls are returned as standard OpenAI `tool_calls`.
|
| 55 |
+
6. On the next request, assistant calls and `role: tool` results are translated back into Solar's conversation format.
|
| 56 |
+
|
| 57 |
+
Tool-aware generations are buffered until validation completes, then emitted as valid OpenAI JSON or SSE. Ordinary chat without tools is streamed token by token directly from Upstage.
|
| 58 |
+
|
| 59 |
+
This is deliberate: ZCode receives a valid tool call or a clean error—never half of a malformed protocol block.
|
| 60 |
+
|
| 61 |
+
## Models
|
| 62 |
+
|
| 63 |
+
The API exposes the four canonical models present in the source provider:
|
| 64 |
+
|
| 65 |
+
| Model ID | Reasoning efforts | Search | Source max output |
|
| 66 |
+
|---|---|---:|---:|
|
| 67 |
+
| `solar-pro3` | `low`, `medium`, `high` | yes | 65,536 |
|
| 68 |
+
| `solar-pro2` | `low`, `high` | yes | 16,383 |
|
| 69 |
+
| `syn-pro` | `low`, `high` | yes | 16,384 |
|
| 70 |
+
| `upstage/solar-1-mini-chat` | none | yes | 16,383 |
|
| 71 |
+
|
| 72 |
+
**Recommended for ZCode:** `solar-pro3`.
|
| 73 |
+
|
| 74 |
+
## Deploy as a public Hugging Face Space
|
| 75 |
+
|
| 76 |
+
### 1. Create the Space
|
| 77 |
+
|
| 78 |
+
1. Open <https://huggingface.co/new-space>.
|
| 79 |
+
2. Choose **Docker** as the SDK.
|
| 80 |
+
3. Choose **Public** visibility.
|
| 81 |
+
4. A free CPU Space is sufficient; inference runs remotely.
|
| 82 |
+
|
| 83 |
+
### 2. Put this directory at the Space repository root
|
| 84 |
+
|
| 85 |
+
The Space root must directly contain `README.md`, `Dockerfile`, `app.py`, `tool_protocol.py`, `upstage_provider.py`, and `requirements.txt`.
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE
|
| 89 |
+
cp -a /path/to/hf-space/. YOUR_SPACE/
|
| 90 |
+
cd YOUR_SPACE
|
| 91 |
+
git add .
|
| 92 |
+
git commit -m "Deploy Upstage Solar OpenAI gateway for ZCode"
|
| 93 |
+
git push
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Hugging Face reads the metadata at the top of this README, builds the Docker image, and exposes port `7860`.
|
| 97 |
+
|
| 98 |
+
No Space secret is required for the requested public deployment. Credentials discovered from the Upstage Console are cached inside the container under `UPSTAGE_CACHE_DIR` and are automatically refreshed.
|
| 99 |
+
|
| 100 |
+
### 3. Verify the deployment
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
BASE=https://YOUR_USERNAME-YOUR_SPACE.hf.space
|
| 104 |
+
curl -sS "$BASE/healthz"
|
| 105 |
+
curl -sS "$BASE/v1/models" | python -m json.tool
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## Connect ZCode
|
| 109 |
+
|
| 110 |
+
ZCode appends `/chat/completions`, so the provider URL must end at **`/v1`**, not at the full endpoint.
|
| 111 |
+
|
| 112 |
+
In ZCode:
|
| 113 |
+
|
| 114 |
+
1. Open **Settings → Model settings**.
|
| 115 |
+
2. Select **Add provider → Create custom provider**.
|
| 116 |
+
3. Choose the **OpenAI-compatible / Chat Completions** API format.
|
| 117 |
+
4. Enter:
|
| 118 |
+
|
| 119 |
+
```text
|
| 120 |
+
Name: Upstage Solar HF
|
| 121 |
+
Base URL: https://YOUR_USERNAME-YOUR_SPACE.hf.space/v1
|
| 122 |
+
API key: public
|
| 123 |
+
Model ID: solar-pro3
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
The API key value is only a placeholder. With the default `PUBLIC_ACCESS=true`, the server accepts requests with no Authorization header and ignores arbitrary Bearer values.
|
| 127 |
+
|
| 128 |
+
Suggested model limits if ZCode asks for them:
|
| 129 |
+
|
| 130 |
+
```text
|
| 131 |
+
Context: 131072
|
| 132 |
+
Output: 65536
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Use the ZCode UI when possible, because its local configuration schema can change. A typical custom-provider shape is:
|
| 136 |
+
|
| 137 |
+
```json
|
| 138 |
+
{
|
| 139 |
+
"provider": {
|
| 140 |
+
"upstage-solar-hf": {
|
| 141 |
+
"name": "Upstage Solar HF",
|
| 142 |
+
"kind": "openai-compatible",
|
| 143 |
+
"source": "custom",
|
| 144 |
+
"options": {
|
| 145 |
+
"baseURL": "https://YOUR_USERNAME-YOUR_SPACE.hf.space/v1",
|
| 146 |
+
"apiKey": "public",
|
| 147 |
+
"apiKeyRequired": false
|
| 148 |
+
},
|
| 149 |
+
"models": {
|
| 150 |
+
"solar-pro3": {
|
| 151 |
+
"name": "Solar Pro 3",
|
| 152 |
+
"limit": {
|
| 153 |
+
"context": 131072,
|
| 154 |
+
"output": 65536
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
ZCode will send its coding functions in `tools`; the gateway handles the call/result loop automatically. The server never executes ZCode tools itself—ZCode remains in control of filesystem and terminal operations.
|
| 164 |
+
|
| 165 |
+
## API examples
|
| 166 |
+
|
| 167 |
+
Set a local or deployed base URL:
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
BASE=http://127.0.0.1:7860
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Non-streaming chat
|
| 174 |
+
|
| 175 |
+
```bash
|
| 176 |
+
curl -sS "$BASE/v1/chat/completions" \
|
| 177 |
+
-H 'Content-Type: application/json' \
|
| 178 |
+
-H 'Authorization: Bearer public' \
|
| 179 |
+
-d '{
|
| 180 |
+
"model": "solar-pro3",
|
| 181 |
+
"messages": [{"role": "user", "content": "Reply with exactly OK"}],
|
| 182 |
+
"stream": false,
|
| 183 |
+
"max_tokens": 64
|
| 184 |
+
}' | python -m json.tool
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
### Realtime streaming with usage
|
| 188 |
+
|
| 189 |
+
```bash
|
| 190 |
+
curl -N "$BASE/v1/chat/completions" \
|
| 191 |
+
-H 'Content-Type: application/json' \
|
| 192 |
+
-d '{
|
| 193 |
+
"model": "solar-pro3",
|
| 194 |
+
"messages": [{"role": "user", "content": "Write a short Python function."}],
|
| 195 |
+
"stream": true,
|
| 196 |
+
"stream_options": {"include_usage": true}
|
| 197 |
+
}'
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
Reasoning is returned separately in `delta.reasoning_content`; it is not mixed into visible answer text.
|
| 201 |
+
|
| 202 |
+
### Function call
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
curl -sS "$BASE/v1/chat/completions" \
|
| 206 |
+
-H 'Content-Type: application/json' \
|
| 207 |
+
-d '{
|
| 208 |
+
"model": "solar-pro3",
|
| 209 |
+
"messages": [{"role": "user", "content": "Read src/app.py before answering."}],
|
| 210 |
+
"tools": [{
|
| 211 |
+
"type": "function",
|
| 212 |
+
"function": {
|
| 213 |
+
"name": "read_file",
|
| 214 |
+
"description": "Read a workspace file",
|
| 215 |
+
"parameters": {
|
| 216 |
+
"type": "object",
|
| 217 |
+
"properties": {"path": {"type": "string"}},
|
| 218 |
+
"required": ["path"],
|
| 219 |
+
"additionalProperties": false
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}],
|
| 223 |
+
"tool_choice": "required"
|
| 224 |
+
}' | python -m json.tool
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
A successful choice has `finish_reason: "tool_calls"`, `message.content: null`, and standard OpenAI `message.tool_calls`.
|
| 228 |
+
|
| 229 |
+
### Web search
|
| 230 |
+
|
| 231 |
+
```bash
|
| 232 |
+
curl -sS "$BASE/v1/chat/completions" \
|
| 233 |
+
-H 'Content-Type: application/json' \
|
| 234 |
+
-d '{
|
| 235 |
+
"model": "solar-pro3",
|
| 236 |
+
"messages": [{"role": "user", "content": "What changed in Python recently?"}],
|
| 237 |
+
"search": true,
|
| 238 |
+
"reasoning_effort": "high"
|
| 239 |
+
}' | python -m json.tool
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
Search uses the source provider's Tavily-backed Upstage mode. Sources are returned in the top-level `sources` array. During streaming, a chunk may carry a top-level `sources` array before answer tokens.
|
| 243 |
+
|
| 244 |
+
ZCode custom providers do not currently expose arbitrary per-request parameters in every UI version, so `search` is primarily useful to direct API clients unless the client lets you add it.
|
| 245 |
+
|
| 246 |
+
### Structured JSON
|
| 247 |
+
|
| 248 |
+
The gateway supports prompt-backed OpenAI response formats:
|
| 249 |
+
|
| 250 |
+
```json
|
| 251 |
+
"response_format": {"type": "json_object"}
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
and:
|
| 255 |
+
|
| 256 |
+
```json
|
| 257 |
+
{
|
| 258 |
+
"response_format": {
|
| 259 |
+
"type": "json_schema",
|
| 260 |
+
"json_schema": {
|
| 261 |
+
"name": "answer",
|
| 262 |
+
"schema": {
|
| 263 |
+
"type": "object",
|
| 264 |
+
"properties": {"answer": {"type": "string"}},
|
| 265 |
+
"required": ["answer"]
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
Unlike function arguments, final structured answer JSON is prompt-backed and is not server-side schema validated.
|
| 273 |
+
|
| 274 |
+
## Supported request fields
|
| 275 |
+
|
| 276 |
+
Core fields:
|
| 277 |
+
|
| 278 |
+
- `model`
|
| 279 |
+
- `messages`
|
| 280 |
+
- `stream`
|
| 281 |
+
- `stream_options.include_usage`
|
| 282 |
+
- `temperature`
|
| 283 |
+
- `max_tokens` or `max_completion_tokens`
|
| 284 |
+
- `reasoning_effort`, or `reasoning: {"effort": ...}`
|
| 285 |
+
- `search`, `web_search`, or `enable_search`
|
| 286 |
+
- `tools`
|
| 287 |
+
- `tool_choice`: `none`, `auto`, `required`, or a specific function
|
| 288 |
+
- `parallel_tool_calls`
|
| 289 |
+
- legacy `functions` and `function_call`
|
| 290 |
+
- `response_format`
|
| 291 |
+
|
| 292 |
+
Only `n=1` is supported. The anonymous source transport is text-only; image/audio content parts return a clean `unsupported_content_type` error rather than being silently discarded.
|
| 293 |
+
|
| 294 |
+
## Configuration
|
| 295 |
+
|
| 296 |
+
All variables are optional:
|
| 297 |
+
|
| 298 |
+
| Variable | Default | Purpose |
|
| 299 |
+
|---|---:|---|
|
| 300 |
+
| `PUBLIC_ACCESS` | `true` | Accept API calls without authentication |
|
| 301 |
+
| `PROVIDER_API_KEY` | empty | Required Bearer key when `PUBLIC_ACCESS=false` |
|
| 302 |
+
| `PORT` | `7860` | HTTP port |
|
| 303 |
+
| `UPSTAGE_CACHE_DIR` | `/home/user/.cache/upstage` in Docker | Credential cache location |
|
| 304 |
+
| `MAX_CONCURRENT_REQUESTS` | `6` | Concurrent Upstage generations |
|
| 305 |
+
| `QUEUE_TIMEOUT_SECONDS` | `20` | Wait for a generation slot |
|
| 306 |
+
| `UPSTREAM_TIMEOUT_SECONDS` | `330` | Per-generation timeout |
|
| 307 |
+
| `UPSTREAM_RETRIES` | `1` | Retry count before any streamed output is committed |
|
| 308 |
+
| `TOOL_PROTOCOL_RETRIES` | `1` | Corrective retries for invalid tool output; capped at 2 |
|
| 309 |
+
| `MAX_BODY_MB` | `4` | Request body limit |
|
| 310 |
+
| `MAX_INPUT_CHARS` | `1000000` | Serialized message limit |
|
| 311 |
+
| `MAX_BUFFERED_OUTPUT_CHARS` | `8000000` | Tool-aware generation buffer limit |
|
| 312 |
+
| `RATE_LIMIT_PER_MINUTE` | `0` | Per-IP in-memory request limit; `0` disables it |
|
| 313 |
+
| `LOG_LEVEL` | `INFO` | Python log level |
|
| 314 |
+
|
| 315 |
+
The requested public deployment defaults to no authentication and no rate limiter so rapid ZCode tool loops are not accidentally blocked. A concurrency limit still protects the Space.
|
| 316 |
+
|
| 317 |
+
For a safer later deployment, set:
|
| 318 |
+
|
| 319 |
+
```text
|
| 320 |
+
PUBLIC_ACCESS=false
|
| 321 |
+
PROVIDER_API_KEY=<long-random-secret>
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
Then use that secret as the ZCode API key.
|
| 325 |
+
|
| 326 |
+
## Run locally
|
| 327 |
+
|
| 328 |
+
### Native Python
|
| 329 |
+
|
| 330 |
+
```bash
|
| 331 |
+
python -m venv .venv
|
| 332 |
+
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 333 |
+
pip install -r requirements-dev.txt
|
| 334 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
### Docker Compose
|
| 338 |
+
|
| 339 |
+
```bash
|
| 340 |
+
cp .env.example .env
|
| 341 |
+
docker compose up --build
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
### Docker directly
|
| 345 |
+
|
| 346 |
+
```bash
|
| 347 |
+
docker build -t upstage-zcode .
|
| 348 |
+
docker run --rm -p 7860:7860 upstage-zcode
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
## Tests
|
| 352 |
+
|
| 353 |
+
Offline unit and adapter tests:
|
| 354 |
+
|
| 355 |
+
```bash
|
| 356 |
+
pytest -q
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
Live source-provider tests, including fresh credential capture, realtime timing, search, history, and reasoning separation:
|
| 360 |
+
|
| 361 |
+
```bash
|
| 362 |
+
UPSTAGE_LIVE=1 pytest -q tests/test_upstage_provider.py -k Live
|
| 363 |
+
```
|
| 364 |
+
|
| 365 |
+
Full running-server smoke test, including an OpenAI tool call and tool-result continuation:
|
| 366 |
+
|
| 367 |
+
```bash
|
| 368 |
+
python smoke_test.py http://127.0.0.1:7860
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
A separate two-step ZCode-style coding probe verifies `tool_choice: auto`, a `read_file` call, its tool result, and a following `write_file` call:
|
| 372 |
+
|
| 373 |
+
```bash
|
| 374 |
+
python live_zcode_test.py http://127.0.0.1:7860
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
Both scripts can also target a deployed Space URL. The sandbox's recorded verification results are in [`TESTING.md`](TESTING.md).
|
| 378 |
+
|
| 379 |
+
## Operational behavior
|
| 380 |
+
|
| 381 |
+
- Credential discovery is lazy. `/healthz` and `/readyz` stay healthy even if Upstage is temporarily unavailable.
|
| 382 |
+
- The first model request may take a few seconds while Next.js action IDs and cookies are captured.
|
| 383 |
+
- Credentials are cached and automatically refreshed after auth rejection.
|
| 384 |
+
- A streaming request is prefetched through its first Upstage event, so early upstream failures are returned as JSON errors instead of a misleading HTTP 200 SSE stream.
|
| 385 |
+
- If an error occurs after streaming has begun, the server emits an SSE `error` object followed by `[DONE]`.
|
| 386 |
+
- Usage uses Upstage counts when non-zero and a clearly internal character-based estimate when the demo stream reports zeros.
|
| 387 |
+
- Logs contain bounded error summaries, never full prompts or discovered credentials.
|
| 388 |
+
- Free Hugging Face Spaces sleep when idle; the first ZCode request after sleep may be slower.
|
| 389 |
+
- Public Spaces can be used by anyone who knows the URL. In-memory limits are per replica and are not a substitute for authentication.
|
TESTING.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Verification record
|
| 2 |
+
|
| 3 |
+
Tested in the Arena sandbox on **2026-10-07** (Asia/Calcutta).
|
| 4 |
+
|
| 5 |
+
## Final results
|
| 6 |
+
|
| 7 |
+
| Layer | Result |
|
| 8 |
+
|---|---|
|
| 9 |
+
| Static Python compilation | passed |
|
| 10 |
+
| Ruff critical checks (`E4,E7,E9,F,I`) on the new API/adapter/tests/scripts | passed |
|
| 11 |
+
| Combined offline suite | **114 passed, 6 live-only skipped** |
|
| 12 |
+
| Original Upstage live suite | **6/6 passed** |
|
| 13 |
+
| Running-server smoke test | all checks passed |
|
| 14 |
+
| Repeated ZCode coding-agent probe | **5/5 complete two-tool loops passed** |
|
| 15 |
+
| OpenAI Python SDK probe | models, JSON chat, SSE+usage, and function call passed |
|
| 16 |
+
| Secret/cache scan | no credential cache, `.env`, PEM, or old-provider reference included |
|
| 17 |
+
|
| 18 |
+
## Live Upstage coverage
|
| 19 |
+
|
| 20 |
+
The copied source suite passed live checks for:
|
| 21 |
+
|
| 22 |
+
1. connection and short chat;
|
| 23 |
+
2. typed realtime streaming;
|
| 24 |
+
3. web-search sources;
|
| 25 |
+
4. multi-turn history;
|
| 26 |
+
5. fresh Next.js credential capture; and
|
| 27 |
+
6. reasoning/content separation with no `<think>` leakage.
|
| 28 |
+
|
| 29 |
+
## Running API coverage
|
| 30 |
+
|
| 31 |
+
`smoke_test.py` passed against native Uvicorn on port 7860:
|
| 32 |
+
|
| 33 |
+
- `/healthz`;
|
| 34 |
+
- four-model discovery;
|
| 35 |
+
- non-streaming Chat Completions;
|
| 36 |
+
- realtime SSE and `[DONE]`;
|
| 37 |
+
- streamed usage;
|
| 38 |
+
- a required OpenAI function call; and
|
| 39 |
+
- a follow-up turn containing an assistant `tool_calls` message and `role: tool` result.
|
| 40 |
+
|
| 41 |
+
`live_zcode_test.py` was then run five consecutive times. Every run completed this two-step `tool_choice: auto` workflow:
|
| 42 |
+
|
| 43 |
+
1. Solar selected `read_file` for `README.md`.
|
| 44 |
+
2. The synthetic file result was returned as an OpenAI tool message.
|
| 45 |
+
3. Solar selected `write_file` with schema-valid `path` and `content` arguments.
|
| 46 |
+
|
| 47 |
+
The repeated run exposed occasional Solar narration instead of a structured call during development. The final adapter now detects that failure mode, performs one strict JSON retry, applies bounded repair only to tool-shaped JSON, validates the selected name and arguments against the caller's schema, and never exposes the narration as a tool call. The five-run verification passed after that fix.
|
| 48 |
+
|
| 49 |
+
## OpenAI SDK coverage
|
| 50 |
+
|
| 51 |
+
Using the official `openai` Python package with:
|
| 52 |
+
|
| 53 |
+
```python
|
| 54 |
+
OpenAI(base_url="http://127.0.0.1:7860/v1", api_key="public")
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
all of the following passed live:
|
| 58 |
+
|
| 59 |
+
- `client.models.list()`;
|
| 60 |
+
- non-streaming `client.chat.completions.create(...)`;
|
| 61 |
+
- streaming chunks plus the usage-only chunk; and
|
| 62 |
+
- `tools` + `tool_choice="required"` returning a parsed SDK `tool_calls` object.
|
| 63 |
+
|
| 64 |
+
## Container note
|
| 65 |
+
|
| 66 |
+
Docker/Podman is unavailable in this sandbox, so the image itself could not be executed here. The Dockerfile was reviewed statically and follows Hugging Face Docker Space conventions: `sdk: docker`, port `7860`, `0.0.0.0`, a health check, and non-root UID 1000. The exact application and pinned runtime dependencies were exercised natively.
|
| 67 |
+
|
| 68 |
+
## Re-run
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
pytest -q
|
| 72 |
+
UPSTAGE_LIVE=1 pytest -q tests/test_upstage_provider.py -k Live
|
| 73 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 74 |
+
python smoke_test.py http://127.0.0.1:7860
|
| 75 |
+
python live_zcode_test.py http://127.0.0.1:7860
|
| 76 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI Chat Completions gateway for Upstage Solar and Z.ai ZCode.
|
| 2 |
+
|
| 3 |
+
The model transport is derived from ``New Upstage Change Logs/upstage_provider.py``.
|
| 4 |
+
This API layer adds canonical OpenAI JSON/SSE responses, request validation,
|
| 5 |
+
reasoning/search metadata, public-Space safety limits, and a constrained function-
|
| 6 |
+
calling adapter for ZCode's agent loop.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import hmac
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
from collections import defaultdict, deque
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from html import escape
|
| 21 |
+
from typing import Any, AsyncGenerator, Callable
|
| 22 |
+
|
| 23 |
+
from fastapi import FastAPI, Request
|
| 24 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 25 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 26 |
+
|
| 27 |
+
from tool_protocol import (
|
| 28 |
+
ParsedToolCall,
|
| 29 |
+
ToolConfiguration,
|
| 30 |
+
ToolProtocolError,
|
| 31 |
+
build_tool_instruction,
|
| 32 |
+
normalize_messages,
|
| 33 |
+
parse_embedded_tool_calls,
|
| 34 |
+
parse_generated_tool_calls,
|
| 35 |
+
parse_tool_configuration,
|
| 36 |
+
)
|
| 37 |
+
from upstage_provider import (
|
| 38 |
+
_MODELS,
|
| 39 |
+
StreamEvent,
|
| 40 |
+
UpstageAuthError,
|
| 41 |
+
UpstageError,
|
| 42 |
+
UpstageProvider,
|
| 43 |
+
UpstageStreamError,
|
| 44 |
+
_resolve_model,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def env_bool(name: str, default: bool) -> bool:
|
| 49 |
+
value = os.getenv(name)
|
| 50 |
+
if value is None:
|
| 51 |
+
return default
|
| 52 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def env_int(name: str, default: int, minimum: int = 0) -> int:
|
| 56 |
+
try:
|
| 57 |
+
return max(minimum, int(os.getenv(name, str(default))))
|
| 58 |
+
except ValueError:
|
| 59 |
+
return max(minimum, default)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def env_float(name: str, default: float, minimum: float = 0.0) -> float:
|
| 63 |
+
try:
|
| 64 |
+
return max(minimum, float(os.getenv(name, str(default))))
|
| 65 |
+
except ValueError:
|
| 66 |
+
return max(minimum, default)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
SERVICE_NAME = os.getenv("SERVICE_NAME", "Upstage Solar for ZCode")
|
| 70 |
+
PUBLIC_ACCESS = env_bool("PUBLIC_ACCESS", True)
|
| 71 |
+
PROVIDER_API_KEY = os.getenv("PROVIDER_API_KEY", "").strip()
|
| 72 |
+
MAX_BODY_MB = env_int("MAX_BODY_MB", 4, 1)
|
| 73 |
+
MAX_BODY_BYTES = MAX_BODY_MB * 1024 * 1024
|
| 74 |
+
MAX_INPUT_CHARS = env_int("MAX_INPUT_CHARS", 1_000_000, 1_000)
|
| 75 |
+
MAX_CONCURRENT_REQUESTS = env_int("MAX_CONCURRENT_REQUESTS", 6, 1)
|
| 76 |
+
QUEUE_TIMEOUT_SECONDS = env_float("QUEUE_TIMEOUT_SECONDS", 20.0, 0.1)
|
| 77 |
+
UPSTREAM_TIMEOUT_SECONDS = env_float("UPSTREAM_TIMEOUT_SECONDS", 330.0, 30.0)
|
| 78 |
+
UPSTREAM_RETRIES = env_int("UPSTREAM_RETRIES", 1, 0)
|
| 79 |
+
RATE_LIMIT_PER_MINUTE = env_int("RATE_LIMIT_PER_MINUTE", 0, 0)
|
| 80 |
+
TOOL_PROTOCOL_RETRIES = min(env_int("TOOL_PROTOCOL_RETRIES", 1, 0), 2)
|
| 81 |
+
MAX_BUFFERED_OUTPUT_CHARS = env_int("MAX_BUFFERED_OUTPUT_CHARS", 8_000_000, 10_000)
|
| 82 |
+
|
| 83 |
+
logging.basicConfig(
|
| 84 |
+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
| 85 |
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
| 86 |
+
)
|
| 87 |
+
logger = logging.getLogger("upstage-zcode")
|
| 88 |
+
|
| 89 |
+
request_slots = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
| 90 |
+
rate_buckets: dict[str, deque[float]] = defaultdict(deque)
|
| 91 |
+
rate_lock = asyncio.Lock()
|
| 92 |
+
provider_factory: Callable[[], UpstageProvider] = UpstageProvider
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class ApiProblem(Exception):
|
| 96 |
+
def __init__(
|
| 97 |
+
self,
|
| 98 |
+
message: str,
|
| 99 |
+
status: int = 400,
|
| 100 |
+
error_type: str = "invalid_request_error",
|
| 101 |
+
code: str = "invalid_request",
|
| 102 |
+
param: str | None = None,
|
| 103 |
+
headers: dict[str, str] | None = None,
|
| 104 |
+
):
|
| 105 |
+
super().__init__(message)
|
| 106 |
+
self.message = message
|
| 107 |
+
self.status = status
|
| 108 |
+
self.error_type = error_type
|
| 109 |
+
self.code = code
|
| 110 |
+
self.param = param
|
| 111 |
+
self.headers = headers or {}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@dataclass
|
| 115 |
+
class ChatRequest:
|
| 116 |
+
model: str
|
| 117 |
+
messages: list[dict[str, str]]
|
| 118 |
+
stream: bool
|
| 119 |
+
include_usage: bool
|
| 120 |
+
temperature: float | None
|
| 121 |
+
max_tokens: int | None
|
| 122 |
+
reasoning: str | None
|
| 123 |
+
search: bool
|
| 124 |
+
tools: ToolConfiguration
|
| 125 |
+
response_instruction: str = ""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class GenerationResult:
|
| 130 |
+
content: str = ""
|
| 131 |
+
reasoning: str = ""
|
| 132 |
+
sources: list[dict[str, Any]] = field(default_factory=list)
|
| 133 |
+
usage: dict[str, int] = field(default_factory=dict)
|
| 134 |
+
tool_calls: list[ParsedToolCall] = field(default_factory=list)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
app = FastAPI(
|
| 138 |
+
title=SERVICE_NAME,
|
| 139 |
+
version="2.0.0",
|
| 140 |
+
description=(
|
| 141 |
+
"OpenAI-compatible Upstage Solar gateway for ZCode, with realtime SSE, "
|
| 142 |
+
"reasoning, web search, and validated function calls."
|
| 143 |
+
),
|
| 144 |
+
)
|
| 145 |
+
app.add_middleware(
|
| 146 |
+
CORSMiddleware,
|
| 147 |
+
allow_origins=["*"],
|
| 148 |
+
allow_credentials=False,
|
| 149 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 150 |
+
allow_headers=["*"],
|
| 151 |
+
expose_headers=["retry-after", "x-request-id", "x-ratelimit-limit"],
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def error_payload(problem: ApiProblem) -> dict[str, Any]:
|
| 156 |
+
return {
|
| 157 |
+
"error": {
|
| 158 |
+
"message": problem.message,
|
| 159 |
+
"type": problem.error_type,
|
| 160 |
+
"param": problem.param,
|
| 161 |
+
"code": problem.code,
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def error_response(problem: ApiProblem, request_id: str | None = None) -> JSONResponse:
|
| 167 |
+
headers = dict(problem.headers)
|
| 168 |
+
if request_id:
|
| 169 |
+
headers["x-request-id"] = request_id
|
| 170 |
+
return JSONResponse(error_payload(problem), status_code=problem.status, headers=headers)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def client_ip(request: Request) -> str:
|
| 174 |
+
forwarded = request.headers.get("x-forwarded-for", "")
|
| 175 |
+
if forwarded:
|
| 176 |
+
return forwarded.split(",", 1)[0].strip()
|
| 177 |
+
return request.client.host if request.client else "unknown"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def authorize(request: Request) -> None:
|
| 181 |
+
if PUBLIC_ACCESS:
|
| 182 |
+
return
|
| 183 |
+
supplied = request.headers.get("authorization", "")
|
| 184 |
+
expected = f"Bearer {PROVIDER_API_KEY}"
|
| 185 |
+
if not PROVIDER_API_KEY or not hmac.compare_digest(supplied, expected):
|
| 186 |
+
raise ApiProblem(
|
| 187 |
+
"Invalid or missing API key.",
|
| 188 |
+
401,
|
| 189 |
+
"authentication_error",
|
| 190 |
+
"invalid_api_key",
|
| 191 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def enforce_rate_limit(request: Request) -> None:
|
| 196 |
+
if RATE_LIMIT_PER_MINUTE <= 0:
|
| 197 |
+
return
|
| 198 |
+
now = time.monotonic()
|
| 199 |
+
key = client_ip(request)
|
| 200 |
+
async with rate_lock:
|
| 201 |
+
bucket = rate_buckets[key]
|
| 202 |
+
while bucket and bucket[0] <= now - 60:
|
| 203 |
+
bucket.popleft()
|
| 204 |
+
if len(bucket) >= RATE_LIMIT_PER_MINUTE:
|
| 205 |
+
retry_after = max(1, int(60 - (now - bucket[0])))
|
| 206 |
+
raise ApiProblem(
|
| 207 |
+
"Public gateway rate limit reached. Retry shortly.",
|
| 208 |
+
429,
|
| 209 |
+
"rate_limit_error",
|
| 210 |
+
"rate_limit_exceeded",
|
| 211 |
+
headers={"Retry-After": str(retry_after)},
|
| 212 |
+
)
|
| 213 |
+
bucket.append(now)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def read_json_body(request: Request) -> dict[str, Any]:
|
| 217 |
+
length = request.headers.get("content-length")
|
| 218 |
+
if length:
|
| 219 |
+
try:
|
| 220 |
+
if int(length) > MAX_BODY_BYTES:
|
| 221 |
+
raise ApiProblem(
|
| 222 |
+
f"Request exceeds the {MAX_BODY_MB} MB limit.",
|
| 223 |
+
413,
|
| 224 |
+
code="request_too_large",
|
| 225 |
+
)
|
| 226 |
+
except ValueError:
|
| 227 |
+
pass
|
| 228 |
+
body = await request.body()
|
| 229 |
+
if len(body) > MAX_BODY_BYTES:
|
| 230 |
+
raise ApiProblem(
|
| 231 |
+
f"Request exceeds the {MAX_BODY_MB} MB limit.",
|
| 232 |
+
413,
|
| 233 |
+
code="request_too_large",
|
| 234 |
+
)
|
| 235 |
+
try:
|
| 236 |
+
payload = json.loads(body)
|
| 237 |
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
| 238 |
+
raise ApiProblem("Invalid JSON body.", code="invalid_json") from exc
|
| 239 |
+
if not isinstance(payload, dict):
|
| 240 |
+
raise ApiProblem("JSON body must be an object.", code="invalid_body")
|
| 241 |
+
return payload
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def parse_chat_request(payload: dict[str, Any]) -> ChatRequest:
|
| 245 |
+
model_raw = payload.get("model")
|
| 246 |
+
if not isinstance(model_raw, str) or not model_raw.strip():
|
| 247 |
+
raise ApiProblem("The 'model' field is required.", param="model", code="missing_model")
|
| 248 |
+
model = _resolve_model(model_raw)
|
| 249 |
+
if model not in _MODELS:
|
| 250 |
+
raise ApiProblem(
|
| 251 |
+
f"Model {model_raw!r} does not exist. Use GET /v1/models.",
|
| 252 |
+
404,
|
| 253 |
+
code="model_not_found",
|
| 254 |
+
param="model",
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
if payload.get("n", 1) != 1:
|
| 258 |
+
raise ApiProblem("Only n=1 is supported.", param="n", code="unsupported_parameter")
|
| 259 |
+
stream = payload.get("stream", False)
|
| 260 |
+
if not isinstance(stream, bool):
|
| 261 |
+
raise ApiProblem("'stream' must be a boolean.", param="stream")
|
| 262 |
+
stream_options = payload.get("stream_options") or {}
|
| 263 |
+
if not isinstance(stream_options, dict):
|
| 264 |
+
raise ApiProblem("'stream_options' must be an object.", param="stream_options")
|
| 265 |
+
include_usage = bool(stream_options.get("include_usage", False))
|
| 266 |
+
|
| 267 |
+
temperature = payload.get("temperature")
|
| 268 |
+
if temperature is not None:
|
| 269 |
+
if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
|
| 270 |
+
raise ApiProblem("'temperature' must be a number.", param="temperature")
|
| 271 |
+
temperature = float(temperature)
|
| 272 |
+
if not 0 <= temperature <= 2:
|
| 273 |
+
raise ApiProblem("'temperature' must be between 0 and 2.", param="temperature")
|
| 274 |
+
|
| 275 |
+
max_tokens = payload.get("max_completion_tokens", payload.get("max_tokens"))
|
| 276 |
+
if max_tokens is not None:
|
| 277 |
+
if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens < 1:
|
| 278 |
+
raise ApiProblem("'max_tokens' must be a positive integer.", param="max_tokens")
|
| 279 |
+
model_limit = int(_MODELS[model]["max_tokens"])
|
| 280 |
+
if max_tokens > model_limit:
|
| 281 |
+
raise ApiProblem(
|
| 282 |
+
f"'max_tokens' exceeds {model!r}'s {model_limit} token limit.",
|
| 283 |
+
param="max_tokens",
|
| 284 |
+
code="context_length_exceeded",
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
reasoning = payload.get("reasoning_effort")
|
| 288 |
+
if reasoning is None and isinstance(payload.get("reasoning"), dict):
|
| 289 |
+
reasoning = payload["reasoning"].get("effort")
|
| 290 |
+
if reasoning is not None:
|
| 291 |
+
valid = _MODELS[model].get("reasoning") or []
|
| 292 |
+
if not isinstance(reasoning, str) or reasoning not in valid:
|
| 293 |
+
raise ApiProblem(
|
| 294 |
+
f"Invalid reasoning effort for {model!r}; choose one of {valid or ['none']}.",
|
| 295 |
+
param="reasoning_effort",
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
search = payload.get("search", payload.get("web_search", payload.get("enable_search", False)))
|
| 299 |
+
if not isinstance(search, bool):
|
| 300 |
+
raise ApiProblem("'search' must be a boolean.", param="search")
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
tools = parse_tool_configuration(payload)
|
| 304 |
+
except ToolProtocolError as exc:
|
| 305 |
+
raise ApiProblem(str(exc), param=exc.param, code=exc.code) from exc
|
| 306 |
+
|
| 307 |
+
response_instruction = ""
|
| 308 |
+
response_format = payload.get("response_format")
|
| 309 |
+
if response_format is not None:
|
| 310 |
+
if not isinstance(response_format, dict):
|
| 311 |
+
raise ApiProblem("'response_format' must be an object.", param="response_format")
|
| 312 |
+
fmt_type = response_format.get("type", "text")
|
| 313 |
+
if fmt_type == "json_object":
|
| 314 |
+
response_instruction = "Return only one valid JSON object, with no Markdown fences."
|
| 315 |
+
elif fmt_type == "json_schema":
|
| 316 |
+
schema_wrapper = response_format.get("json_schema")
|
| 317 |
+
if not isinstance(schema_wrapper, dict):
|
| 318 |
+
raise ApiProblem(
|
| 319 |
+
"'response_format.json_schema' must be an object.",
|
| 320 |
+
param="response_format.json_schema",
|
| 321 |
+
)
|
| 322 |
+
response_instruction = (
|
| 323 |
+
"Return only JSON conforming to this response schema, with no Markdown fences:\n"
|
| 324 |
+
+ json.dumps(schema_wrapper, ensure_ascii=False, separators=(",", ":"))
|
| 325 |
+
)
|
| 326 |
+
elif fmt_type != "text":
|
| 327 |
+
raise ApiProblem(
|
| 328 |
+
f"Unsupported response format {fmt_type!r}.",
|
| 329 |
+
param="response_format.type",
|
| 330 |
+
code="unsupported_parameter",
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
instruction_parts = [part for part in (response_instruction, build_tool_instruction(tools)) if part]
|
| 334 |
+
try:
|
| 335 |
+
messages = normalize_messages(payload.get("messages"), "\n\n".join(instruction_parts))
|
| 336 |
+
except ToolProtocolError as exc:
|
| 337 |
+
raise ApiProblem(str(exc), param=exc.param, code=exc.code) from exc
|
| 338 |
+
serialized_chars = len(json.dumps(messages, ensure_ascii=False))
|
| 339 |
+
if serialized_chars > MAX_INPUT_CHARS:
|
| 340 |
+
raise ApiProblem(
|
| 341 |
+
f"Serialized messages exceed the {MAX_INPUT_CHARS} character limit.",
|
| 342 |
+
413,
|
| 343 |
+
code="input_too_large",
|
| 344 |
+
param="messages",
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
return ChatRequest(
|
| 348 |
+
model=model,
|
| 349 |
+
messages=messages,
|
| 350 |
+
stream=stream,
|
| 351 |
+
include_usage=include_usage,
|
| 352 |
+
temperature=temperature,
|
| 353 |
+
max_tokens=max_tokens,
|
| 354 |
+
reasoning=reasoning,
|
| 355 |
+
search=search,
|
| 356 |
+
tools=tools,
|
| 357 |
+
response_instruction=response_instruction,
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def estimate_tokens(text: str) -> int:
|
| 362 |
+
return max(1, round(len(text) / 4)) if text else 0
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def usage_from_provider(provider: Any, messages: list[dict[str, str]], content: str, reasoning: str) -> dict[str, int]:
|
| 366 |
+
last = getattr(provider, "last_usage", None)
|
| 367 |
+
api_usage = getattr(last, "api_usage", None) if last else None
|
| 368 |
+
if isinstance(api_usage, dict) and api_usage.get("total_tokens"):
|
| 369 |
+
prompt = int(api_usage.get("prompt_tokens") or 0)
|
| 370 |
+
completion = int(api_usage.get("completion_tokens") or 0)
|
| 371 |
+
total = int(api_usage.get("total_tokens") or prompt + completion)
|
| 372 |
+
else:
|
| 373 |
+
prompt = estimate_tokens(json.dumps(messages, ensure_ascii=False))
|
| 374 |
+
completion = estimate_tokens(content + reasoning)
|
| 375 |
+
total = prompt + completion
|
| 376 |
+
result: dict[str, Any] = {
|
| 377 |
+
"prompt_tokens": prompt,
|
| 378 |
+
"completion_tokens": completion,
|
| 379 |
+
"total_tokens": total,
|
| 380 |
+
}
|
| 381 |
+
if reasoning:
|
| 382 |
+
result["completion_tokens_details"] = {"reasoning_tokens": estimate_tokens(reasoning)}
|
| 383 |
+
return result
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def add_usage(a: dict[str, Any], b: dict[str, Any]) -> dict[str, int]:
|
| 387 |
+
return {
|
| 388 |
+
"prompt_tokens": int(a.get("prompt_tokens", 0)) + int(b.get("prompt_tokens", 0)),
|
| 389 |
+
"completion_tokens": int(a.get("completion_tokens", 0)) + int(b.get("completion_tokens", 0)),
|
| 390 |
+
"total_tokens": int(a.get("total_tokens", 0)) + int(b.get("total_tokens", 0)),
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def provider_stream(provider: Any, chat: ChatRequest, messages: list[dict[str, str]] | None = None):
|
| 395 |
+
return provider.stream(
|
| 396 |
+
messages=messages or chat.messages,
|
| 397 |
+
model=chat.model,
|
| 398 |
+
reasoning=chat.reasoning,
|
| 399 |
+
search=chat.search,
|
| 400 |
+
max_tokens=chat.max_tokens,
|
| 401 |
+
temperature=chat.temperature,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
async def collect_once(chat: ChatRequest, messages: list[dict[str, str]]) -> GenerationResult:
|
| 406 |
+
provider = provider_factory()
|
| 407 |
+
content_parts: list[str] = []
|
| 408 |
+
reasoning_parts: list[str] = []
|
| 409 |
+
sources: list[dict[str, Any]] = []
|
| 410 |
+
async with asyncio.timeout(UPSTREAM_TIMEOUT_SECONDS):
|
| 411 |
+
async for event in provider_stream(provider, chat, messages):
|
| 412 |
+
if event.kind == "content":
|
| 413 |
+
content_parts.append(event.text)
|
| 414 |
+
elif event.kind == "thinking":
|
| 415 |
+
reasoning_parts.append(event.text)
|
| 416 |
+
elif event.kind == "sources":
|
| 417 |
+
try:
|
| 418 |
+
parsed = json.loads(event.text)
|
| 419 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("sources"), list):
|
| 420 |
+
sources = parsed["sources"]
|
| 421 |
+
except json.JSONDecodeError:
|
| 422 |
+
pass
|
| 423 |
+
if sum(map(len, content_parts)) + sum(map(len, reasoning_parts)) > MAX_BUFFERED_OUTPUT_CHARS:
|
| 424 |
+
raise ApiProblem(
|
| 425 |
+
"The buffered model output exceeded the server safety limit.",
|
| 426 |
+
502,
|
| 427 |
+
"server_error",
|
| 428 |
+
"output_too_large",
|
| 429 |
+
)
|
| 430 |
+
content = "".join(content_parts)
|
| 431 |
+
reasoning = "".join(reasoning_parts)
|
| 432 |
+
if not sources:
|
| 433 |
+
sources = list(getattr(provider, "last_sources", []) or [])
|
| 434 |
+
return GenerationResult(
|
| 435 |
+
content=content,
|
| 436 |
+
reasoning=reasoning,
|
| 437 |
+
sources=sources,
|
| 438 |
+
usage=usage_from_provider(provider, messages, content, reasoning),
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
async def collect_with_retry(chat: ChatRequest, messages: list[dict[str, str]]) -> GenerationResult:
|
| 443 |
+
last_error: Exception | None = None
|
| 444 |
+
for attempt in range(UPSTREAM_RETRIES + 1):
|
| 445 |
+
try:
|
| 446 |
+
return await collect_once(chat, messages)
|
| 447 |
+
except ApiProblem:
|
| 448 |
+
raise
|
| 449 |
+
except (UpstageError, TimeoutError, OSError) as exc:
|
| 450 |
+
last_error = exc
|
| 451 |
+
if attempt >= UPSTREAM_RETRIES:
|
| 452 |
+
break
|
| 453 |
+
logger.warning("upstream generation failed; retrying attempt=%s error=%s", attempt + 1, type(exc).__name__)
|
| 454 |
+
await asyncio.sleep(min(2.0, 0.4 * (2**attempt)))
|
| 455 |
+
assert last_error is not None
|
| 456 |
+
raise last_error
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def narrates_unemitted_tool_call(content: str, tools: ToolConfiguration) -> bool:
|
| 460 |
+
"""Detect Solar planning to call a tool but returning narration instead.
|
| 461 |
+
|
| 462 |
+
This narrow heuristic is only used to trigger the bounded protocol retry. It
|
| 463 |
+
avoids turning ordinary ``auto`` answers into forced calls.
|
| 464 |
+
"""
|
| 465 |
+
text = " ".join(content.lower().split())
|
| 466 |
+
if not text:
|
| 467 |
+
return False
|
| 468 |
+
mentions_catalog = any(tool.name.lower() in text for tool in tools.tools)
|
| 469 |
+
intent_patterns = (
|
| 470 |
+
r"\b(?:i|we)\s+(?:will|need to|should|must|have to)\s+(?:now\s+)?(?:use|call|invoke|read|write|edit|create)",
|
| 471 |
+
r"\b(?:i'll|we'll)\s+(?:now\s+)?(?:use|call|invoke|read|write|edit|create)",
|
| 472 |
+
r"\b(?:use|using|call|calling|invoke|invoking)\s+(?:the\s+)?[a-z0-9_-]+\s+(?:tool|function)\b",
|
| 473 |
+
r"\b(?:respond|proceed)\s+with\s+(?:the\s+)?(?:a\s+)?tool call\b",
|
| 474 |
+
)
|
| 475 |
+
return mentions_catalog and any(re.search(pattern, text) for pattern in intent_patterns)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def last_mentioned_tool(content: str, tools: ToolConfiguration) -> str | None:
|
| 479 |
+
"""Return the catalog function mentioned latest in a narrated response."""
|
| 480 |
+
lowered = content.lower()
|
| 481 |
+
positions = [
|
| 482 |
+
(lowered.rfind(tool.name.lower()), tool.name)
|
| 483 |
+
for tool in tools.tools
|
| 484 |
+
if lowered.rfind(tool.name.lower()) >= 0
|
| 485 |
+
]
|
| 486 |
+
return max(positions, default=(-1, None))[1]
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
async def generate_buffered(chat: ChatRequest) -> GenerationResult:
|
| 490 |
+
"""Collect a response, validating/retrying prompt-level function calls."""
|
| 491 |
+
messages = list(chat.messages)
|
| 492 |
+
aggregate_usage: dict[str, Any] = {}
|
| 493 |
+
last_problem: ToolProtocolError | None = None
|
| 494 |
+
for attempt in range(TOOL_PROTOCOL_RETRIES + 1):
|
| 495 |
+
result = await collect_with_retry(chat, messages)
|
| 496 |
+
aggregate_usage = add_usage(aggregate_usage, result.usage)
|
| 497 |
+
try:
|
| 498 |
+
narrated_intent = narrates_unemitted_tool_call(result.content, chat.tools)
|
| 499 |
+
try:
|
| 500 |
+
calls = parse_generated_tool_calls(result.content, chat.tools)
|
| 501 |
+
except ToolProtocolError:
|
| 502 |
+
calls = (
|
| 503 |
+
parse_embedded_tool_calls(result.content, chat.tools)
|
| 504 |
+
if narrated_intent
|
| 505 |
+
else []
|
| 506 |
+
)
|
| 507 |
+
if not calls:
|
| 508 |
+
raise
|
| 509 |
+
if not calls and narrated_intent:
|
| 510 |
+
calls = parse_embedded_tool_calls(result.content, chat.tools)
|
| 511 |
+
if not calls:
|
| 512 |
+
raise ToolProtocolError(
|
| 513 |
+
"The model narrated an intended tool call instead of emitting it.",
|
| 514 |
+
code="invalid_tool_output",
|
| 515 |
+
)
|
| 516 |
+
if chat.tools.must_call and not calls:
|
| 517 |
+
raise ToolProtocolError(
|
| 518 |
+
"The model did not emit the required function call.",
|
| 519 |
+
code="invalid_tool_output",
|
| 520 |
+
)
|
| 521 |
+
result.tool_calls = calls
|
| 522 |
+
result.usage = aggregate_usage
|
| 523 |
+
return result
|
| 524 |
+
except ToolProtocolError as exc:
|
| 525 |
+
last_problem = exc
|
| 526 |
+
if attempt >= TOOL_PROTOCOL_RETRIES:
|
| 527 |
+
break
|
| 528 |
+
logger.warning("tool output validation failed; retrying attempt=%s error=%s", attempt + 1, str(exc)[:200])
|
| 529 |
+
messages = [message.copy() for message in messages]
|
| 530 |
+
target = chat.tools.required_name or last_mentioned_tool(
|
| 531 |
+
result.content, chat.tools
|
| 532 |
+
)
|
| 533 |
+
target_text = (
|
| 534 |
+
f" The required function is {target!r}." if target else ""
|
| 535 |
+
)
|
| 536 |
+
strict_retry = (
|
| 537 |
+
"CORRECTION FOR THIS RETRY: A function call is now mandatory."
|
| 538 |
+
f"{target_text} Return exactly one plain JSON object with keys "
|
| 539 |
+
'"name" and "arguments", where "arguments" is a JSON object matching '
|
| 540 |
+
"the function schema. The first character must be { and the last must "
|
| 541 |
+
"be }. Do not use special tokens, XML, Markdown, explanation, planning, "
|
| 542 |
+
"or apologies."
|
| 543 |
+
)
|
| 544 |
+
if messages and messages[0].get("role") == "system":
|
| 545 |
+
messages[0]["content"] += "\n\n" + strict_retry
|
| 546 |
+
else:
|
| 547 |
+
messages.insert(0, {"role": "system", "content": strict_retry})
|
| 548 |
+
# Do not replay the model's narration as an assistant turn: it tends to
|
| 549 |
+
# anchor Solar into continuing the same prose. Retry from the original
|
| 550 |
+
# conversation with one terse correction instead.
|
| 551 |
+
messages.append(
|
| 552 |
+
{
|
| 553 |
+
"role": "user",
|
| 554 |
+
"content": (
|
| 555 |
+
"TOOL GATEWAY VALIDATION ERROR: "
|
| 556 |
+
f"{exc}. Emit the function call now as one plain JSON object: "
|
| 557 |
+
'{"name":"FUNCTION_NAME","arguments":{}}. Return no prose.'
|
| 558 |
+
),
|
| 559 |
+
}
|
| 560 |
+
)
|
| 561 |
+
raise ApiProblem(
|
| 562 |
+
f"Solar could not produce a valid function call after retry: {last_problem}",
|
| 563 |
+
502,
|
| 564 |
+
"server_error",
|
| 565 |
+
"invalid_upstream_tool_call",
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def upstream_problem(exc: Exception) -> ApiProblem:
|
| 570 |
+
if isinstance(exc, ApiProblem):
|
| 571 |
+
return exc
|
| 572 |
+
if isinstance(exc, (TimeoutError, asyncio.TimeoutError)):
|
| 573 |
+
return ApiProblem(
|
| 574 |
+
"Upstage timed out while generating the response.",
|
| 575 |
+
504,
|
| 576 |
+
"server_error",
|
| 577 |
+
"upstream_timeout",
|
| 578 |
+
)
|
| 579 |
+
if isinstance(exc, UpstageAuthError):
|
| 580 |
+
return ApiProblem(
|
| 581 |
+
"Could not establish an anonymous Upstage Console session.",
|
| 582 |
+
502,
|
| 583 |
+
"server_error",
|
| 584 |
+
"upstream_auth_error",
|
| 585 |
+
)
|
| 586 |
+
if isinstance(exc, (UpstageStreamError, UpstageError, OSError)):
|
| 587 |
+
return ApiProblem(
|
| 588 |
+
f"Upstage generation failed: {str(exc)[:240]}",
|
| 589 |
+
502,
|
| 590 |
+
"server_error",
|
| 591 |
+
"upstream_error",
|
| 592 |
+
)
|
| 593 |
+
logger.exception("unexpected generation error", exc_info=exc)
|
| 594 |
+
return ApiProblem(
|
| 595 |
+
"Internal server error.", 500, "server_error", "internal_error"
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def model_object(model_id: str) -> dict[str, Any]:
|
| 600 |
+
cfg = _MODELS[model_id]
|
| 601 |
+
return {
|
| 602 |
+
"id": model_id,
|
| 603 |
+
"object": "model",
|
| 604 |
+
"created": 0,
|
| 605 |
+
"owned_by": "upstage",
|
| 606 |
+
"permission": [],
|
| 607 |
+
"capabilities": {
|
| 608 |
+
"chat_completions": True,
|
| 609 |
+
"streaming": True,
|
| 610 |
+
"tools": True,
|
| 611 |
+
"tool_transport": "validated_prompt_adapter",
|
| 612 |
+
"reasoning": cfg.get("reasoning") or [],
|
| 613 |
+
"web_search": bool(cfg.get("search")),
|
| 614 |
+
"max_output_tokens": cfg.get("max_tokens"),
|
| 615 |
+
},
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def chat_id() -> str:
|
| 620 |
+
return f"chatcmpl-{uuid.uuid4().hex}"
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def completion_json(chat: ChatRequest, result: GenerationResult, request_id: str) -> dict[str, Any]:
|
| 624 |
+
message: dict[str, Any] = {"role": "assistant"}
|
| 625 |
+
if result.tool_calls:
|
| 626 |
+
message["content"] = None
|
| 627 |
+
message["tool_calls"] = [call.as_openai() for call in result.tool_calls]
|
| 628 |
+
finish_reason = "tool_calls"
|
| 629 |
+
else:
|
| 630 |
+
message["content"] = result.content
|
| 631 |
+
finish_reason = "stop"
|
| 632 |
+
if result.reasoning:
|
| 633 |
+
message["reasoning_content"] = result.reasoning
|
| 634 |
+
body: dict[str, Any] = {
|
| 635 |
+
"id": chat_id(),
|
| 636 |
+
"object": "chat.completion",
|
| 637 |
+
"created": int(time.time()),
|
| 638 |
+
"model": chat.model,
|
| 639 |
+
"system_fingerprint": "upstage-console-zcode-v2",
|
| 640 |
+
"choices": [{"index": 0, "message": message, "logprobs": None, "finish_reason": finish_reason}],
|
| 641 |
+
"usage": result.usage,
|
| 642 |
+
}
|
| 643 |
+
if result.sources:
|
| 644 |
+
body["sources"] = result.sources
|
| 645 |
+
return body
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def sse(data: dict[str, Any] | str) -> bytes:
|
| 649 |
+
encoded = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
| 650 |
+
return f"data: {encoded}\n\n".encode("utf-8")
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def stream_chunk(
|
| 654 |
+
completion_id: str,
|
| 655 |
+
created: int,
|
| 656 |
+
model: str,
|
| 657 |
+
delta: dict[str, Any],
|
| 658 |
+
finish_reason: str | None = None,
|
| 659 |
+
**extra: Any,
|
| 660 |
+
) -> dict[str, Any]:
|
| 661 |
+
body: dict[str, Any] = {
|
| 662 |
+
"id": completion_id,
|
| 663 |
+
"object": "chat.completion.chunk",
|
| 664 |
+
"created": created,
|
| 665 |
+
"model": model,
|
| 666 |
+
"system_fingerprint": "upstage-console-zcode-v2",
|
| 667 |
+
"choices": [{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish_reason}],
|
| 668 |
+
}
|
| 669 |
+
body.update(extra)
|
| 670 |
+
return body
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
async def buffered_sse(chat: ChatRequest, result: GenerationResult) -> AsyncGenerator[bytes, None]:
|
| 674 |
+
cid, created = chat_id(), int(time.time())
|
| 675 |
+
yield sse(stream_chunk(cid, created, chat.model, {"role": "assistant", "content": ""}))
|
| 676 |
+
if result.sources:
|
| 677 |
+
yield sse(stream_chunk(cid, created, chat.model, {}, sources=result.sources))
|
| 678 |
+
if result.reasoning:
|
| 679 |
+
for offset in range(0, len(result.reasoning), 512):
|
| 680 |
+
yield sse(stream_chunk(cid, created, chat.model, {"reasoning_content": result.reasoning[offset:offset + 512]}))
|
| 681 |
+
if result.tool_calls:
|
| 682 |
+
for index, call in enumerate(result.tool_calls):
|
| 683 |
+
item = call.as_openai()
|
| 684 |
+
item["index"] = index
|
| 685 |
+
yield sse(stream_chunk(cid, created, chat.model, {"tool_calls": [item]}))
|
| 686 |
+
finish = "tool_calls"
|
| 687 |
+
else:
|
| 688 |
+
for offset in range(0, len(result.content), 512):
|
| 689 |
+
yield sse(stream_chunk(cid, created, chat.model, {"content": result.content[offset:offset + 512]}))
|
| 690 |
+
finish = "stop"
|
| 691 |
+
yield sse(stream_chunk(cid, created, chat.model, {}, finish))
|
| 692 |
+
if chat.include_usage:
|
| 693 |
+
yield sse(
|
| 694 |
+
{
|
| 695 |
+
"id": cid,
|
| 696 |
+
"object": "chat.completion.chunk",
|
| 697 |
+
"created": created,
|
| 698 |
+
"model": chat.model,
|
| 699 |
+
"system_fingerprint": "upstage-console-zcode-v2",
|
| 700 |
+
"choices": [],
|
| 701 |
+
"usage": result.usage,
|
| 702 |
+
}
|
| 703 |
+
)
|
| 704 |
+
yield sse("[DONE]")
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
async def acquire_slot() -> None:
|
| 708 |
+
try:
|
| 709 |
+
await asyncio.wait_for(request_slots.acquire(), QUEUE_TIMEOUT_SECONDS)
|
| 710 |
+
except TimeoutError as exc:
|
| 711 |
+
raise ApiProblem(
|
| 712 |
+
"The gateway is busy. Retry shortly.",
|
| 713 |
+
503,
|
| 714 |
+
"server_error",
|
| 715 |
+
"gateway_busy",
|
| 716 |
+
headers={"Retry-After": "5"},
|
| 717 |
+
) from exc
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
async def prepare_realtime(chat: ChatRequest):
|
| 721 |
+
"""Open a provider stream and prefetch one event so early failures stay JSON."""
|
| 722 |
+
last_error: Exception | None = None
|
| 723 |
+
for attempt in range(UPSTREAM_RETRIES + 1):
|
| 724 |
+
provider = provider_factory()
|
| 725 |
+
iterator = provider_stream(provider, chat).__aiter__()
|
| 726 |
+
try:
|
| 727 |
+
first = await asyncio.wait_for(anext(iterator), UPSTREAM_TIMEOUT_SECONDS)
|
| 728 |
+
return provider, iterator, first
|
| 729 |
+
except StopAsyncIteration:
|
| 730 |
+
return provider, iterator, StreamEvent("done", "")
|
| 731 |
+
except (UpstageError, TimeoutError, OSError) as exc:
|
| 732 |
+
last_error = exc
|
| 733 |
+
try:
|
| 734 |
+
await iterator.aclose()
|
| 735 |
+
except Exception:
|
| 736 |
+
pass
|
| 737 |
+
if attempt >= UPSTREAM_RETRIES:
|
| 738 |
+
break
|
| 739 |
+
logger.warning("realtime upstream prefetch failed; retrying error=%s", type(exc).__name__)
|
| 740 |
+
await asyncio.sleep(min(2.0, 0.4 * (2**attempt)))
|
| 741 |
+
assert last_error is not None
|
| 742 |
+
raise last_error
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
async def realtime_sse(
|
| 746 |
+
chat: ChatRequest,
|
| 747 |
+
provider: Any,
|
| 748 |
+
iterator: Any,
|
| 749 |
+
first: StreamEvent,
|
| 750 |
+
) -> AsyncGenerator[bytes, None]:
|
| 751 |
+
cid, created = chat_id(), int(time.time())
|
| 752 |
+
sources: list[dict[str, Any]] = []
|
| 753 |
+
try:
|
| 754 |
+
yield sse(stream_chunk(cid, created, chat.model, {"role": "assistant", "content": ""}))
|
| 755 |
+
|
| 756 |
+
async def emit(event: StreamEvent):
|
| 757 |
+
nonlocal sources
|
| 758 |
+
if event.kind == "content":
|
| 759 |
+
return sse(stream_chunk(cid, created, chat.model, {"content": event.text}))
|
| 760 |
+
if event.kind == "thinking":
|
| 761 |
+
return sse(stream_chunk(cid, created, chat.model, {"reasoning_content": event.text}))
|
| 762 |
+
if event.kind == "sources":
|
| 763 |
+
try:
|
| 764 |
+
parsed = json.loads(event.text)
|
| 765 |
+
sources = parsed.get("sources", []) if isinstance(parsed, dict) else []
|
| 766 |
+
except json.JSONDecodeError:
|
| 767 |
+
sources = []
|
| 768 |
+
return sse(stream_chunk(cid, created, chat.model, {}, sources=sources))
|
| 769 |
+
return None
|
| 770 |
+
|
| 771 |
+
chunk = await emit(first)
|
| 772 |
+
if chunk:
|
| 773 |
+
yield chunk
|
| 774 |
+
async with asyncio.timeout(UPSTREAM_TIMEOUT_SECONDS):
|
| 775 |
+
async for event in iterator:
|
| 776 |
+
chunk = await emit(event)
|
| 777 |
+
if chunk:
|
| 778 |
+
yield chunk
|
| 779 |
+
yield sse(stream_chunk(cid, created, chat.model, {}, "stop"))
|
| 780 |
+
if chat.include_usage:
|
| 781 |
+
content = getattr(provider, "last_response", "") or ""
|
| 782 |
+
reasoning = getattr(provider, "last_reasoning", "") or ""
|
| 783 |
+
usage = usage_from_provider(provider, chat.messages, content, reasoning)
|
| 784 |
+
yield sse(
|
| 785 |
+
{
|
| 786 |
+
"id": cid,
|
| 787 |
+
"object": "chat.completion.chunk",
|
| 788 |
+
"created": created,
|
| 789 |
+
"model": chat.model,
|
| 790 |
+
"system_fingerprint": "upstage-console-zcode-v2",
|
| 791 |
+
"choices": [],
|
| 792 |
+
"usage": usage,
|
| 793 |
+
}
|
| 794 |
+
)
|
| 795 |
+
yield sse("[DONE]")
|
| 796 |
+
except asyncio.CancelledError:
|
| 797 |
+
raise
|
| 798 |
+
except Exception as exc:
|
| 799 |
+
problem = upstream_problem(exc)
|
| 800 |
+
logger.warning("stream failed code=%s error=%s", problem.code, problem.message[:240])
|
| 801 |
+
yield sse(error_payload(problem))
|
| 802 |
+
yield sse("[DONE]")
|
| 803 |
+
finally:
|
| 804 |
+
try:
|
| 805 |
+
await iterator.aclose()
|
| 806 |
+
except Exception:
|
| 807 |
+
pass
|
| 808 |
+
request_slots.release()
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
@app.get("/healthz", include_in_schema=False)
|
| 812 |
+
async def healthz() -> dict[str, Any]:
|
| 813 |
+
return {"ok": True, "service": SERVICE_NAME, "version": "2.0.0"}
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
@app.get("/readyz", include_in_schema=False)
|
| 817 |
+
async def readyz() -> dict[str, Any]:
|
| 818 |
+
# Upstream credential capture is intentionally lazy: a transient Upstage outage
|
| 819 |
+
# must not cause Hugging Face to restart an otherwise healthy container.
|
| 820 |
+
return {"ready": True, "upstream_credentials": "lazy"}
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
@app.get("/favicon.ico", include_in_schema=False)
|
| 824 |
+
async def favicon() -> Response:
|
| 825 |
+
return Response(status_code=204)
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
@app.get("/v1/models")
|
| 829 |
+
async def list_models(request: Request) -> Response:
|
| 830 |
+
try:
|
| 831 |
+
await authorize(request)
|
| 832 |
+
return JSONResponse({"object": "list", "data": [model_object(name) for name in _MODELS]})
|
| 833 |
+
except ApiProblem as problem:
|
| 834 |
+
return error_response(problem)
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
@app.get("/v1/models/{model_id:path}")
|
| 838 |
+
async def retrieve_model(model_id: str, request: Request) -> Response:
|
| 839 |
+
try:
|
| 840 |
+
await authorize(request)
|
| 841 |
+
model = _resolve_model(model_id)
|
| 842 |
+
if model not in _MODELS:
|
| 843 |
+
raise ApiProblem(f"Model {model_id!r} was not found.", 404, code="model_not_found", param="model")
|
| 844 |
+
return JSONResponse(model_object(model))
|
| 845 |
+
except ApiProblem as problem:
|
| 846 |
+
return error_response(problem)
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
@app.post("/v1/chat/completions")
|
| 850 |
+
async def chat_completions(request: Request) -> Response:
|
| 851 |
+
request_id = request.headers.get("x-request-id") or f"req_{uuid.uuid4().hex}"
|
| 852 |
+
slot_acquired = False
|
| 853 |
+
try:
|
| 854 |
+
await authorize(request)
|
| 855 |
+
await enforce_rate_limit(request)
|
| 856 |
+
payload = await read_json_body(request)
|
| 857 |
+
chat = parse_chat_request(payload)
|
| 858 |
+
await acquire_slot()
|
| 859 |
+
slot_acquired = True
|
| 860 |
+
|
| 861 |
+
# Tool-aware responses are buffered so malformed model protocol never leaks
|
| 862 |
+
# into ZCode as ordinary text. Standard chat remains token-by-token realtime.
|
| 863 |
+
if chat.tools.enabled:
|
| 864 |
+
result = await generate_buffered(chat)
|
| 865 |
+
request_slots.release()
|
| 866 |
+
slot_acquired = False
|
| 867 |
+
if chat.stream:
|
| 868 |
+
return StreamingResponse(
|
| 869 |
+
buffered_sse(chat, result),
|
| 870 |
+
media_type="text/event-stream",
|
| 871 |
+
headers={
|
| 872 |
+
"Cache-Control": "no-cache",
|
| 873 |
+
"X-Accel-Buffering": "no",
|
| 874 |
+
"x-request-id": request_id,
|
| 875 |
+
},
|
| 876 |
+
)
|
| 877 |
+
return JSONResponse(
|
| 878 |
+
completion_json(chat, result, request_id),
|
| 879 |
+
headers={"x-request-id": request_id},
|
| 880 |
+
)
|
| 881 |
+
|
| 882 |
+
if chat.stream:
|
| 883 |
+
provider, iterator, first = await prepare_realtime(chat)
|
| 884 |
+
# realtime_sse owns and releases this slot from here onward.
|
| 885 |
+
slot_acquired = False
|
| 886 |
+
return StreamingResponse(
|
| 887 |
+
realtime_sse(chat, provider, iterator, first),
|
| 888 |
+
media_type="text/event-stream",
|
| 889 |
+
headers={
|
| 890 |
+
"Cache-Control": "no-cache",
|
| 891 |
+
"X-Accel-Buffering": "no",
|
| 892 |
+
"x-request-id": request_id,
|
| 893 |
+
},
|
| 894 |
+
)
|
| 895 |
+
|
| 896 |
+
result = await collect_with_retry(chat, chat.messages)
|
| 897 |
+
request_slots.release()
|
| 898 |
+
slot_acquired = False
|
| 899 |
+
return JSONResponse(
|
| 900 |
+
completion_json(chat, result, request_id),
|
| 901 |
+
headers={"x-request-id": request_id},
|
| 902 |
+
)
|
| 903 |
+
except Exception as exc:
|
| 904 |
+
if slot_acquired:
|
| 905 |
+
request_slots.release()
|
| 906 |
+
problem = upstream_problem(exc)
|
| 907 |
+
logger.warning("request failed request_id=%s code=%s error=%s", request_id, problem.code, problem.message[:240])
|
| 908 |
+
return error_response(problem, request_id)
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 912 |
+
async def home() -> HTMLResponse:
|
| 913 |
+
models = "".join(f"<code>{escape(name)}</code>" for name in _MODELS)
|
| 914 |
+
return HTMLResponse(
|
| 915 |
+
f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 916 |
+
<title>{escape(SERVICE_NAME)}</title><style>
|
| 917 |
+
:root{{--bg:#07111f;--card:#0d1b2e;--line:#203552;--text:#eaf2ff;--muted:#98abc7;--sun:#ffbf47;--ok:#53dda3}}*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;background:radial-gradient(circle at 20% 0,#16335b 0,transparent 37%),var(--bg);color:var(--text);font:16px/1.55 ui-sans-serif,system-ui,sans-serif;display:grid;place-items:center;padding:28px}}main{{width:min(820px,100%);background:linear-gradient(145deg,rgba(18,40,70,.95),rgba(8,20,36,.98));border:1px solid var(--line);border-radius:24px;padding:clamp(26px,6vw,54px);box-shadow:0 30px 90px #0008}}.eyebrow{{color:var(--sun);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:.78rem}}h1{{font-size:clamp(2rem,6vw,4rem);line-height:1.02;margin:.35em 0}}p{{color:var(--muted);max-width:65ch}}.status{{display:inline-flex;align-items:center;gap:9px;color:var(--ok);font-weight:750}}.dot{{width:9px;height:9px;background:var(--ok);border-radius:50%;box-shadow:0 0 20px var(--ok)}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin:28px 0}}.box{{padding:18px;border:1px solid var(--line);border-radius:15px;background:#071423aa}}.box b{{display:block;margin-bottom:7px}}code{{display:inline-block;background:#06101d;border:1px solid var(--line);border-radius:8px;padding:5px 9px;margin:4px;color:#cbe0ff}}a{{color:#7db7ff}}
|
| 918 |
+
</style></head><body><main><div class="eyebrow">Upstage Solar · OpenAI compatible</div><h1>Built for ZCode.</h1><div class="status"><span class="dot"></span>Gateway healthy</div><p>Realtime Chat Completions, separated reasoning, optional web search, and schema-validated OpenAI function calls over the anonymous Upstage Console transport.</p><div class="grid"><div class="box"><b>Provider base URL</b><code>/v1</code></div><div class="box"><b>API key</b><code>public</code></div><div class="box"><b>Endpoints</b><a href="/v1/models">Models</a> · <a href="/docs">API docs</a></div></div><p>{models}</p><p><small>This is an independent compatibility gateway, not an official Upstage service.</small></p></main></body></html>"""
|
| 919 |
+
)
|
compose.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
upstage-zcode:
|
| 3 |
+
build: .
|
| 4 |
+
ports:
|
| 5 |
+
- "7860:7860"
|
| 6 |
+
env_file:
|
| 7 |
+
- path: .env
|
| 8 |
+
required: false
|
| 9 |
+
environment:
|
| 10 |
+
PORT: "7860"
|
| 11 |
+
UPSTAGE_CACHE_DIR: /home/user/.cache/upstage
|
| 12 |
+
volumes:
|
| 13 |
+
- upstage-cache:/home/user/.cache/upstage
|
| 14 |
+
restart: unless-stopped
|
| 15 |
+
|
| 16 |
+
volumes:
|
| 17 |
+
upstage-cache:
|
live_zcode_test.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Live two-step coding-agent probe against a running gateway.
|
| 3 |
+
|
| 4 |
+
It verifies that an OpenAI ``auto`` tool loop selects ``read_file``, accepts the
|
| 5 |
+
returned tool result, then selects ``write_file`` with schema-valid arguments.
|
| 6 |
+
The script does not execute either call.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
import urllib.error
|
| 13 |
+
import urllib.request
|
| 14 |
+
|
| 15 |
+
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:7860").rstrip("/")
|
| 16 |
+
URL = BASE + "/v1/chat/completions"
|
| 17 |
+
TOOLS = [
|
| 18 |
+
{
|
| 19 |
+
"type": "function",
|
| 20 |
+
"function": {
|
| 21 |
+
"name": "read_file",
|
| 22 |
+
"description": "Read a workspace file",
|
| 23 |
+
"parameters": {
|
| 24 |
+
"type": "object",
|
| 25 |
+
"properties": {"path": {"type": "string"}},
|
| 26 |
+
"required": ["path"],
|
| 27 |
+
"additionalProperties": False,
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"type": "function",
|
| 33 |
+
"function": {
|
| 34 |
+
"name": "write_file",
|
| 35 |
+
"description": "Create or overwrite a workspace file",
|
| 36 |
+
"parameters": {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {
|
| 39 |
+
"path": {"type": "string"},
|
| 40 |
+
"content": {"type": "string"},
|
| 41 |
+
},
|
| 42 |
+
"required": ["path", "content"],
|
| 43 |
+
"additionalProperties": False,
|
| 44 |
+
},
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"type": "function",
|
| 49 |
+
"function": {
|
| 50 |
+
"name": "execute_command",
|
| 51 |
+
"description": "Execute a shell command",
|
| 52 |
+
"parameters": {
|
| 53 |
+
"type": "object",
|
| 54 |
+
"properties": {"command": {"type": "string"}},
|
| 55 |
+
"required": ["command"],
|
| 56 |
+
"additionalProperties": False,
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def post(messages):
|
| 64 |
+
payload = {
|
| 65 |
+
"model": "solar-pro3",
|
| 66 |
+
"messages": messages,
|
| 67 |
+
"tools": TOOLS,
|
| 68 |
+
"tool_choice": "auto",
|
| 69 |
+
"temperature": 0.1,
|
| 70 |
+
"max_tokens": 512,
|
| 71 |
+
}
|
| 72 |
+
req = urllib.request.Request(
|
| 73 |
+
URL,
|
| 74 |
+
data=json.dumps(payload).encode(),
|
| 75 |
+
headers={"Content-Type": "application/json", "Authorization": "Bearer public"},
|
| 76 |
+
)
|
| 77 |
+
try:
|
| 78 |
+
with urllib.request.urlopen(req, timeout=360) as response:
|
| 79 |
+
return json.loads(response.read())
|
| 80 |
+
except urllib.error.HTTPError as exc:
|
| 81 |
+
raise AssertionError(f"HTTP {exc.code}: {exc.read().decode()}") from exc
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main():
|
| 85 |
+
system = {"role": "system", "content": "You are a coding agent. Inspect files before editing."}
|
| 86 |
+
user = {
|
| 87 |
+
"role": "user",
|
| 88 |
+
"content": "Inspect README.md using the appropriate tool before you explain the project.",
|
| 89 |
+
}
|
| 90 |
+
first = post([system, user])
|
| 91 |
+
first_choice = first["choices"][0]
|
| 92 |
+
assert first_choice["finish_reason"] == "tool_calls", first
|
| 93 |
+
first_call = first_choice["message"]["tool_calls"][0]
|
| 94 |
+
assert first_call["function"]["name"] == "read_file", first_call
|
| 95 |
+
assert json.loads(first_call["function"]["arguments"])["path"] == "README.md"
|
| 96 |
+
print("PASS auto selected read_file")
|
| 97 |
+
|
| 98 |
+
second = post(
|
| 99 |
+
[
|
| 100 |
+
system,
|
| 101 |
+
user,
|
| 102 |
+
first_choice["message"],
|
| 103 |
+
{
|
| 104 |
+
"role": "tool",
|
| 105 |
+
"tool_call_id": first_call["id"],
|
| 106 |
+
"content": "# Demo Project\nA Python demo.",
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"role": "user",
|
| 110 |
+
"content": (
|
| 111 |
+
'Now create hello.py containing a hello() function that returns "world". '
|
| 112 |
+
"Use the appropriate tool."
|
| 113 |
+
),
|
| 114 |
+
},
|
| 115 |
+
]
|
| 116 |
+
)
|
| 117 |
+
second_choice = second["choices"][0]
|
| 118 |
+
assert second_choice["finish_reason"] == "tool_calls", second
|
| 119 |
+
second_call = second_choice["message"]["tool_calls"][0]
|
| 120 |
+
assert second_call["function"]["name"] == "write_file", second_call
|
| 121 |
+
arguments = json.loads(second_call["function"]["arguments"])
|
| 122 |
+
assert arguments["path"] == "hello.py"
|
| 123 |
+
assert "def hello" in arguments["content"] and "world" in arguments["content"]
|
| 124 |
+
print("PASS tool result accepted; auto selected write_file")
|
| 125 |
+
print("ZCODE CODING-AGENT PROBE PASSED")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
main()
|
pytest.ini
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
| 3 |
+
testpaths = tests
|
| 4 |
+
addopts = -ra
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
httpx==0.28.1
|
| 3 |
+
pytest==9.0.3
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.3
|
| 3 |
+
curl_cffi==0.16.3
|
| 4 |
+
jsonschema==4.26.0
|
smoke_test.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""End-to-end smoke test for a running local or deployed gateway."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
|
| 10 |
+
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:7860").rstrip("/")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def request(path: str, payload=None, stream=False):
|
| 14 |
+
data = json.dumps(payload).encode() if payload is not None else None
|
| 15 |
+
req = urllib.request.Request(
|
| 16 |
+
BASE + path,
|
| 17 |
+
data=data,
|
| 18 |
+
headers={
|
| 19 |
+
"Content-Type": "application/json",
|
| 20 |
+
"Authorization": "Bearer public",
|
| 21 |
+
"User-Agent": "upstage-zcode-smoke/2.0",
|
| 22 |
+
},
|
| 23 |
+
)
|
| 24 |
+
try:
|
| 25 |
+
response = urllib.request.urlopen(req, timeout=360)
|
| 26 |
+
raw = response.read().decode("utf-8", "replace")
|
| 27 |
+
return response.status, raw
|
| 28 |
+
except urllib.error.HTTPError as exc:
|
| 29 |
+
raise AssertionError(f"HTTP {exc.code} {path}: {exc.read().decode()}") from exc
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def tool_definition():
|
| 33 |
+
return {
|
| 34 |
+
"type": "function",
|
| 35 |
+
"function": {
|
| 36 |
+
"name": "get_weather",
|
| 37 |
+
"description": "Get the current weather in a city",
|
| 38 |
+
"parameters": {
|
| 39 |
+
"type": "object",
|
| 40 |
+
"properties": {"city": {"type": "string"}},
|
| 41 |
+
"required": ["city"],
|
| 42 |
+
"additionalProperties": False,
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def main():
|
| 49 |
+
status, raw = request("/healthz")
|
| 50 |
+
assert status == 200 and json.loads(raw)["ok"] is True
|
| 51 |
+
print("PASS health")
|
| 52 |
+
|
| 53 |
+
_, raw = request("/v1/models")
|
| 54 |
+
ids = [item["id"] for item in json.loads(raw)["data"]]
|
| 55 |
+
assert "solar-pro3" in ids and "solar-pro2" in ids
|
| 56 |
+
print(f"PASS models ({len(ids)})")
|
| 57 |
+
|
| 58 |
+
_, raw = request(
|
| 59 |
+
"/v1/chat/completions",
|
| 60 |
+
{
|
| 61 |
+
"model": "solar-pro3",
|
| 62 |
+
"stream": False,
|
| 63 |
+
"messages": [{"role": "user", "content": "Reply with exactly OK"}],
|
| 64 |
+
"max_tokens": 32,
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
body = json.loads(raw)
|
| 68 |
+
text = body["choices"][0]["message"]["content"]
|
| 69 |
+
assert text and "OK" in text.upper()
|
| 70 |
+
assert body["usage"]["total_tokens"] > 0
|
| 71 |
+
print("PASS non-streaming chat")
|
| 72 |
+
|
| 73 |
+
_, raw = request(
|
| 74 |
+
"/v1/chat/completions",
|
| 75 |
+
{
|
| 76 |
+
"model": "solar-pro3",
|
| 77 |
+
"stream": True,
|
| 78 |
+
"stream_options": {"include_usage": True},
|
| 79 |
+
"messages": [{"role": "user", "content": "Reply with exactly STREAM_OK"}],
|
| 80 |
+
"max_tokens": 32,
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
chunks = []
|
| 84 |
+
for line in raw.splitlines():
|
| 85 |
+
if not line.startswith("data: ") or line == "data: [DONE]":
|
| 86 |
+
continue
|
| 87 |
+
item = json.loads(line[6:])
|
| 88 |
+
choices = item.get("choices") or []
|
| 89 |
+
if choices:
|
| 90 |
+
chunks.append(choices[0].get("delta", {}).get("content", ""))
|
| 91 |
+
assert "STREAM_OK" in "".join(chunks)
|
| 92 |
+
assert "data: [DONE]" in raw
|
| 93 |
+
assert '"choices":[]' in raw and '"usage"' in raw
|
| 94 |
+
print("PASS realtime SSE + usage")
|
| 95 |
+
|
| 96 |
+
_, raw = request(
|
| 97 |
+
"/v1/chat/completions",
|
| 98 |
+
{
|
| 99 |
+
"model": "solar-pro3",
|
| 100 |
+
"stream": False,
|
| 101 |
+
"messages": [
|
| 102 |
+
{
|
| 103 |
+
"role": "user",
|
| 104 |
+
"content": "Use get_weather to check Delhi. Do not guess the weather.",
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
+
"tools": [tool_definition()],
|
| 108 |
+
"tool_choice": "required",
|
| 109 |
+
"temperature": 0.1,
|
| 110 |
+
"max_tokens": 256,
|
| 111 |
+
},
|
| 112 |
+
)
|
| 113 |
+
body = json.loads(raw)
|
| 114 |
+
choice = body["choices"][0]
|
| 115 |
+
assert choice["finish_reason"] == "tool_calls"
|
| 116 |
+
call = choice["message"]["tool_calls"][0]
|
| 117 |
+
assert call["function"]["name"] == "get_weather"
|
| 118 |
+
arguments = json.loads(call["function"]["arguments"])
|
| 119 |
+
assert "delhi" in arguments["city"].lower()
|
| 120 |
+
print("PASS required function call")
|
| 121 |
+
|
| 122 |
+
_, raw = request(
|
| 123 |
+
"/v1/chat/completions",
|
| 124 |
+
{
|
| 125 |
+
"model": "solar-pro3",
|
| 126 |
+
"stream": False,
|
| 127 |
+
"messages": [
|
| 128 |
+
{"role": "user", "content": "Use get_weather to check Delhi, then summarize."},
|
| 129 |
+
{"role": "assistant", "content": None, "tool_calls": [call]},
|
| 130 |
+
{
|
| 131 |
+
"role": "tool",
|
| 132 |
+
"tool_call_id": call["id"],
|
| 133 |
+
"content": json.dumps({"city": "Delhi", "temperature_c": 31, "condition": "clear"}),
|
| 134 |
+
},
|
| 135 |
+
],
|
| 136 |
+
"tools": [tool_definition()],
|
| 137 |
+
"tool_choice": "auto",
|
| 138 |
+
"temperature": 0.1,
|
| 139 |
+
"max_tokens": 256,
|
| 140 |
+
},
|
| 141 |
+
)
|
| 142 |
+
body = json.loads(raw)
|
| 143 |
+
followup = body["choices"][0]
|
| 144 |
+
assert followup["finish_reason"] == "stop"
|
| 145 |
+
assert followup["message"]["content"]
|
| 146 |
+
print("PASS tool-result continuation")
|
| 147 |
+
print("ALL SMOKE TESTS PASSED")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from collections import deque
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
import app as gateway
|
| 11 |
+
from upstage_provider import StreamEvent, UpstageStreamError
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def anyio_backend():
|
| 16 |
+
return "asyncio"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FakeProvider:
|
| 20 |
+
def __init__(self, events=None, error=None, capture=None):
|
| 21 |
+
self.events = events or []
|
| 22 |
+
self.error = error
|
| 23 |
+
self.capture = capture
|
| 24 |
+
self.last_response = ""
|
| 25 |
+
self.last_reasoning = ""
|
| 26 |
+
self.last_sources = []
|
| 27 |
+
self.last_usage = None
|
| 28 |
+
|
| 29 |
+
async def stream(self, **kwargs):
|
| 30 |
+
if self.capture is not None:
|
| 31 |
+
self.capture.append(kwargs)
|
| 32 |
+
if self.error:
|
| 33 |
+
raise self.error
|
| 34 |
+
content, reasoning = [], []
|
| 35 |
+
for event in self.events:
|
| 36 |
+
if event.kind == "content":
|
| 37 |
+
content.append(event.text)
|
| 38 |
+
elif event.kind == "thinking":
|
| 39 |
+
reasoning.append(event.text)
|
| 40 |
+
elif event.kind == "sources":
|
| 41 |
+
self.last_sources = json.loads(event.text).get("sources", [])
|
| 42 |
+
yield event
|
| 43 |
+
self.last_response = "".join(content)
|
| 44 |
+
self.last_reasoning = "".join(reasoning)
|
| 45 |
+
self.last_usage = SimpleNamespace(
|
| 46 |
+
api_usage={"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def factory_queue(monkeypatch, providers):
|
| 51 |
+
queue = deque(providers)
|
| 52 |
+
monkeypatch.setattr(gateway, "provider_factory", lambda: queue.popleft())
|
| 53 |
+
return queue
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture(autouse=True)
|
| 57 |
+
def reset_config(monkeypatch):
|
| 58 |
+
gateway.rate_buckets.clear()
|
| 59 |
+
monkeypatch.setattr(gateway, "PUBLIC_ACCESS", True)
|
| 60 |
+
monkeypatch.setattr(gateway, "RATE_LIMIT_PER_MINUTE", 0)
|
| 61 |
+
monkeypatch.setattr(gateway, "UPSTREAM_RETRIES", 0)
|
| 62 |
+
monkeypatch.setattr(gateway, "TOOL_PROTOCOL_RETRIES", 1)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.fixture
|
| 66 |
+
def client():
|
| 67 |
+
return httpx.AsyncClient(
|
| 68 |
+
transport=httpx.ASGITransport(app=gateway.app), base_url="http://test"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@pytest.mark.anyio
|
| 73 |
+
async def test_health_home_and_models(client):
|
| 74 |
+
health = await client.get("/healthz")
|
| 75 |
+
ready = await client.get("/readyz")
|
| 76 |
+
home = await client.get("/")
|
| 77 |
+
models = await client.get("/v1/models")
|
| 78 |
+
one = await client.get("/v1/models/solar-pro3")
|
| 79 |
+
assert health.status_code == ready.status_code == home.status_code == 200
|
| 80 |
+
assert "Built for ZCode" in home.text
|
| 81 |
+
assert [item["id"] for item in models.json()["data"]] == [
|
| 82 |
+
"solar-pro3",
|
| 83 |
+
"solar-pro2",
|
| 84 |
+
"syn-pro",
|
| 85 |
+
"upstage/solar-1-mini-chat",
|
| 86 |
+
]
|
| 87 |
+
assert one.json()["capabilities"]["tools"] is True
|
| 88 |
+
await client.aclose()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@pytest.mark.anyio
|
| 92 |
+
async def test_nonstream_chat_reasoning_sources_and_usage(client, monkeypatch):
|
| 93 |
+
sources = {"sources": [{"title": "A", "url": "https://a.test", "score": 0.9}]}
|
| 94 |
+
fake = FakeProvider(
|
| 95 |
+
[
|
| 96 |
+
StreamEvent("sources", json.dumps(sources)),
|
| 97 |
+
StreamEvent("thinking", "hmm"),
|
| 98 |
+
StreamEvent("content", "O"),
|
| 99 |
+
StreamEvent("content", "K"),
|
| 100 |
+
StreamEvent("done", ""),
|
| 101 |
+
]
|
| 102 |
+
)
|
| 103 |
+
factory_queue(monkeypatch, [fake])
|
| 104 |
+
response = await client.post(
|
| 105 |
+
"/v1/chat/completions",
|
| 106 |
+
json={
|
| 107 |
+
"model": "solar-pro3",
|
| 108 |
+
"messages": [{"role": "user", "content": "hello"}],
|
| 109 |
+
"search": True,
|
| 110 |
+
"reasoning_effort": "high",
|
| 111 |
+
},
|
| 112 |
+
)
|
| 113 |
+
assert response.status_code == 200
|
| 114 |
+
body = response.json()
|
| 115 |
+
assert body["choices"][0]["message"]["content"] == "OK"
|
| 116 |
+
assert body["choices"][0]["message"]["reasoning_content"] == "hmm"
|
| 117 |
+
assert body["sources"][0]["url"] == "https://a.test"
|
| 118 |
+
assert body["usage"]["total_tokens"] == 8
|
| 119 |
+
await client.aclose()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@pytest.mark.anyio
|
| 123 |
+
async def test_realtime_sse_and_usage(client, monkeypatch):
|
| 124 |
+
fake = FakeProvider(
|
| 125 |
+
[
|
| 126 |
+
StreamEvent("thinking", "plan"),
|
| 127 |
+
StreamEvent("content", "Hel"),
|
| 128 |
+
StreamEvent("content", "lo"),
|
| 129 |
+
StreamEvent("done", ""),
|
| 130 |
+
]
|
| 131 |
+
)
|
| 132 |
+
factory_queue(monkeypatch, [fake])
|
| 133 |
+
async with client.stream(
|
| 134 |
+
"POST",
|
| 135 |
+
"/v1/chat/completions",
|
| 136 |
+
json={
|
| 137 |
+
"model": "solar-pro3",
|
| 138 |
+
"stream": True,
|
| 139 |
+
"stream_options": {"include_usage": True},
|
| 140 |
+
"messages": [{"role": "user", "content": "hello"}],
|
| 141 |
+
},
|
| 142 |
+
) as response:
|
| 143 |
+
text = (await response.aread()).decode()
|
| 144 |
+
assert response.status_code == 200
|
| 145 |
+
assert response.headers["content-type"].startswith("text/event-stream")
|
| 146 |
+
assert '"reasoning_content":"plan"' in text
|
| 147 |
+
assert '"content":"Hel"' in text and '"content":"lo"' in text
|
| 148 |
+
assert '"choices":[],"usage"' in text
|
| 149 |
+
assert text.rstrip().endswith("data: [DONE]")
|
| 150 |
+
await client.aclose()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def tool_payload(stream=False, choice="required"):
|
| 154 |
+
return {
|
| 155 |
+
"model": "solar-pro3",
|
| 156 |
+
"stream": stream,
|
| 157 |
+
"stream_options": {"include_usage": True},
|
| 158 |
+
"messages": [{"role": "user", "content": "Read app.py"}],
|
| 159 |
+
"tools": [
|
| 160 |
+
{
|
| 161 |
+
"type": "function",
|
| 162 |
+
"function": {
|
| 163 |
+
"name": "read_file",
|
| 164 |
+
"description": "Read a file",
|
| 165 |
+
"parameters": {
|
| 166 |
+
"type": "object",
|
| 167 |
+
"properties": {"path": {"type": "string"}},
|
| 168 |
+
"required": ["path"],
|
| 169 |
+
"additionalProperties": False,
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
}
|
| 173 |
+
],
|
| 174 |
+
"tool_choice": choice,
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
TOOL_WIRE = (
|
| 179 |
+
'<|tool_call:begin|>q123q<|tool_call:name|>read_file'
|
| 180 |
+
'<|tool_call:args|>{"path":"app.py"}<|tool_call:end|>'
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@pytest.mark.anyio
|
| 185 |
+
async def test_nonstream_validated_tool_call(client, monkeypatch):
|
| 186 |
+
capture = []
|
| 187 |
+
fake = FakeProvider([StreamEvent("content", TOOL_WIRE), StreamEvent("done", "")], capture=capture)
|
| 188 |
+
factory_queue(monkeypatch, [fake])
|
| 189 |
+
response = await client.post("/v1/chat/completions", json=tool_payload())
|
| 190 |
+
assert response.status_code == 200
|
| 191 |
+
choice = response.json()["choices"][0]
|
| 192 |
+
assert choice["finish_reason"] == "tool_calls"
|
| 193 |
+
call = choice["message"]["tool_calls"][0]
|
| 194 |
+
assert call["function"]["name"] == "read_file"
|
| 195 |
+
assert json.loads(call["function"]["arguments"]) == {"path": "app.py"}
|
| 196 |
+
assert "TOOL GATEWAY PROTOCOL" in capture[0]["messages"][0]["content"]
|
| 197 |
+
await client.aclose()
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@pytest.mark.anyio
|
| 201 |
+
async def test_streamed_tool_call_is_openai_sse(client, monkeypatch):
|
| 202 |
+
fake = FakeProvider(
|
| 203 |
+
[StreamEvent("thinking", "need file"), StreamEvent("content", TOOL_WIRE), StreamEvent("done", "")]
|
| 204 |
+
)
|
| 205 |
+
factory_queue(monkeypatch, [fake])
|
| 206 |
+
response = await client.post("/v1/chat/completions", json=tool_payload(stream=True))
|
| 207 |
+
text = response.text
|
| 208 |
+
assert response.status_code == 200
|
| 209 |
+
assert '"tool_calls":[{"id":"call_' in text
|
| 210 |
+
assert '"finish_reason":"tool_calls"' in text
|
| 211 |
+
assert '"reasoning_content":"need file"' in text
|
| 212 |
+
assert TOOL_WIRE not in text
|
| 213 |
+
assert "data: [DONE]" in text
|
| 214 |
+
await client.aclose()
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@pytest.mark.anyio
|
| 218 |
+
async def test_required_tool_output_retries_once(client, monkeypatch):
|
| 219 |
+
first = FakeProvider([StreamEvent("content", "I forgot the tool"), StreamEvent("done", "")])
|
| 220 |
+
second_capture = []
|
| 221 |
+
second = FakeProvider(
|
| 222 |
+
[StreamEvent("content", TOOL_WIRE), StreamEvent("done", "")],
|
| 223 |
+
capture=second_capture,
|
| 224 |
+
)
|
| 225 |
+
queue = factory_queue(monkeypatch, [first, second])
|
| 226 |
+
response = await client.post("/v1/chat/completions", json=tool_payload())
|
| 227 |
+
assert response.status_code == 200 and not queue
|
| 228 |
+
retry_messages = second_capture[0]["messages"]
|
| 229 |
+
assert "VALIDATION ERROR" in retry_messages[-1]["content"]
|
| 230 |
+
assert response.json()["usage"]["total_tokens"] == 16
|
| 231 |
+
await client.aclose()
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@pytest.mark.anyio
|
| 235 |
+
async def test_auto_tool_choice_allows_plain_answer(client, monkeypatch):
|
| 236 |
+
fake = FakeProvider([StreamEvent("content", "No tool needed."), StreamEvent("done", "")])
|
| 237 |
+
factory_queue(monkeypatch, [fake])
|
| 238 |
+
response = await client.post("/v1/chat/completions", json=tool_payload(choice="auto"))
|
| 239 |
+
assert response.status_code == 200
|
| 240 |
+
choice = response.json()["choices"][0]
|
| 241 |
+
assert choice["finish_reason"] == "stop"
|
| 242 |
+
assert choice["message"]["content"] == "No tool needed."
|
| 243 |
+
await client.aclose()
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@pytest.mark.anyio
|
| 247 |
+
async def test_auto_retries_narrated_tool_intent(client, monkeypatch):
|
| 248 |
+
first = FakeProvider(
|
| 249 |
+
[
|
| 250 |
+
StreamEvent(
|
| 251 |
+
"content",
|
| 252 |
+
"We need to write the file using the read_file tool. I will call it now.",
|
| 253 |
+
),
|
| 254 |
+
StreamEvent("done", ""),
|
| 255 |
+
]
|
| 256 |
+
)
|
| 257 |
+
second = FakeProvider([StreamEvent("content", TOOL_WIRE), StreamEvent("done", "")])
|
| 258 |
+
queue = factory_queue(monkeypatch, [first, second])
|
| 259 |
+
response = await client.post("/v1/chat/completions", json=tool_payload(choice="auto"))
|
| 260 |
+
assert response.status_code == 200 and not queue
|
| 261 |
+
assert response.json()["choices"][0]["finish_reason"] == "tool_calls"
|
| 262 |
+
await client.aclose()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@pytest.mark.anyio
|
| 266 |
+
async def test_tool_result_round_trip_is_normalized(client, monkeypatch):
|
| 267 |
+
capture = []
|
| 268 |
+
fake = FakeProvider([StreamEvent("content", "The file says OK."), StreamEvent("done", "")], capture=capture)
|
| 269 |
+
factory_queue(monkeypatch, [fake])
|
| 270 |
+
payload = tool_payload(choice="auto")
|
| 271 |
+
payload["messages"] = [
|
| 272 |
+
{"role": "user", "content": "Read app.py"},
|
| 273 |
+
{
|
| 274 |
+
"role": "assistant",
|
| 275 |
+
"content": None,
|
| 276 |
+
"tool_calls": [
|
| 277 |
+
{
|
| 278 |
+
"id": "call_previous",
|
| 279 |
+
"type": "function",
|
| 280 |
+
"function": {"name": "read_file", "arguments": '{"path":"app.py"}'},
|
| 281 |
+
}
|
| 282 |
+
],
|
| 283 |
+
},
|
| 284 |
+
{"role": "tool", "tool_call_id": "call_previous", "content": "OK"},
|
| 285 |
+
]
|
| 286 |
+
response = await client.post("/v1/chat/completions", json=payload)
|
| 287 |
+
assert response.status_code == 200
|
| 288 |
+
sent = capture[0]["messages"]
|
| 289 |
+
assert "<|tool_call:name|>read_file" in sent[-2]["content"]
|
| 290 |
+
assert "TOOL RESULT" in sent[-1]["content"]
|
| 291 |
+
assert "function: read_file" in sent[-1]["content"]
|
| 292 |
+
await client.aclose()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@pytest.mark.anyio
|
| 296 |
+
async def test_upstream_prefetch_error_stays_json(client, monkeypatch):
|
| 297 |
+
monkeypatch.setattr(gateway, "UPSTREAM_RETRIES", 0)
|
| 298 |
+
factory_queue(monkeypatch, [FakeProvider(error=UpstageStreamError("boom"))])
|
| 299 |
+
response = await client.post(
|
| 300 |
+
"/v1/chat/completions",
|
| 301 |
+
json={
|
| 302 |
+
"model": "solar-pro3",
|
| 303 |
+
"stream": True,
|
| 304 |
+
"messages": [{"role": "user", "content": "hi"}],
|
| 305 |
+
},
|
| 306 |
+
)
|
| 307 |
+
assert response.status_code == 502
|
| 308 |
+
assert response.headers["content-type"].startswith("application/json")
|
| 309 |
+
assert response.json()["error"]["code"] == "upstream_error"
|
| 310 |
+
await client.aclose()
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@pytest.mark.anyio
|
| 314 |
+
async def test_transient_generation_retry(client, monkeypatch):
|
| 315 |
+
monkeypatch.setattr(gateway, "UPSTREAM_RETRIES", 1)
|
| 316 |
+
bad = FakeProvider(error=UpstageStreamError("temporary"))
|
| 317 |
+
good = FakeProvider([StreamEvent("content", "OK"), StreamEvent("done", "")])
|
| 318 |
+
queue = factory_queue(monkeypatch, [bad, good])
|
| 319 |
+
response = await client.post(
|
| 320 |
+
"/v1/chat/completions",
|
| 321 |
+
json={"model": "solar-pro3", "messages": [{"role": "user", "content": "hi"}]},
|
| 322 |
+
)
|
| 323 |
+
assert response.status_code == 200 and not queue
|
| 324 |
+
assert response.json()["choices"][0]["message"]["content"] == "OK"
|
| 325 |
+
await client.aclose()
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@pytest.mark.anyio
|
| 329 |
+
@pytest.mark.parametrize(
|
| 330 |
+
"payload,code,status",
|
| 331 |
+
[
|
| 332 |
+
({}, "missing_model", 400),
|
| 333 |
+
({"model": "nope", "messages": [{"role": "user", "content": "x"}]}, "model_not_found", 404),
|
| 334 |
+
({"model": "solar-pro3", "messages": []}, "invalid_messages", 400),
|
| 335 |
+
({"model": "solar-pro3", "messages": [{"role": "user", "content": "x"}], "n": 2}, "unsupported_parameter", 400),
|
| 336 |
+
({"model": "solar-pro3", "messages": [{"role": "user", "content": "x"}], "max_tokens": 999999}, "context_length_exceeded", 400),
|
| 337 |
+
],
|
| 338 |
+
)
|
| 339 |
+
async def test_clean_validation_errors(client, payload, code, status):
|
| 340 |
+
response = await client.post("/v1/chat/completions", json=payload)
|
| 341 |
+
assert response.status_code == status
|
| 342 |
+
assert response.json()["error"]["code"] == code
|
| 343 |
+
await client.aclose()
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
@pytest.mark.anyio
|
| 347 |
+
async def test_invalid_json_and_unsupported_image(client):
|
| 348 |
+
bad = await client.post(
|
| 349 |
+
"/v1/chat/completions",
|
| 350 |
+
content=b"{bad",
|
| 351 |
+
headers={"content-type": "application/json"},
|
| 352 |
+
)
|
| 353 |
+
image = await client.post(
|
| 354 |
+
"/v1/chat/completions",
|
| 355 |
+
json={
|
| 356 |
+
"model": "solar-pro3",
|
| 357 |
+
"messages": [
|
| 358 |
+
{
|
| 359 |
+
"role": "user",
|
| 360 |
+
"content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}],
|
| 361 |
+
}
|
| 362 |
+
],
|
| 363 |
+
},
|
| 364 |
+
)
|
| 365 |
+
assert bad.status_code == 400 and bad.json()["error"]["code"] == "invalid_json"
|
| 366 |
+
assert image.status_code == 400
|
| 367 |
+
assert image.json()["error"]["code"] == "unsupported_content_type"
|
| 368 |
+
await client.aclose()
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@pytest.mark.anyio
|
| 372 |
+
async def test_optional_auth(client, monkeypatch):
|
| 373 |
+
monkeypatch.setattr(gateway, "PUBLIC_ACCESS", False)
|
| 374 |
+
monkeypatch.setattr(gateway, "PROVIDER_API_KEY", "secret")
|
| 375 |
+
denied = await client.get("/v1/models")
|
| 376 |
+
allowed = await client.get("/v1/models", headers={"authorization": "Bearer secret"})
|
| 377 |
+
assert denied.status_code == 401
|
| 378 |
+
assert allowed.status_code == 200
|
| 379 |
+
await client.aclose()
|
tests/test_tool_protocol.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from tool_protocol import (
|
| 8 |
+
ToolProtocolError,
|
| 9 |
+
build_tool_instruction,
|
| 10 |
+
normalize_messages,
|
| 11 |
+
parse_embedded_tool_calls,
|
| 12 |
+
parse_generated_tool_calls,
|
| 13 |
+
parse_tool_configuration,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def payload(**overrides):
|
| 18 |
+
base = {
|
| 19 |
+
"tools": [
|
| 20 |
+
{
|
| 21 |
+
"type": "function",
|
| 22 |
+
"function": {
|
| 23 |
+
"name": "read_file",
|
| 24 |
+
"description": "Read a file",
|
| 25 |
+
"parameters": {
|
| 26 |
+
"type": "object",
|
| 27 |
+
"properties": {"path": {"type": "string"}},
|
| 28 |
+
"required": ["path"],
|
| 29 |
+
"additionalProperties": False,
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
]
|
| 34 |
+
}
|
| 35 |
+
base.update(overrides)
|
| 36 |
+
return base
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def native(name="read_file", arguments=None, call_id="q123q"):
|
| 40 |
+
arguments = arguments or {"path": "src/app.py"}
|
| 41 |
+
return (
|
| 42 |
+
f"<|tool_call:begin|>{call_id}<|tool_call:name|>{name}"
|
| 43 |
+
f"<|tool_call:args|>{json.dumps(arguments)}<|tool_call:end|>"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_default_auto_and_instruction():
|
| 48 |
+
config = parse_tool_configuration(payload())
|
| 49 |
+
assert config.choice == "auto" and config.enabled
|
| 50 |
+
instruction = build_tool_instruction(config)
|
| 51 |
+
assert "read_file" in instruction
|
| 52 |
+
assert "<|tool_call:begin|>" in instruction
|
| 53 |
+
assert "answer the user normally" in instruction
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_required_and_specific_choices():
|
| 57 |
+
required = parse_tool_configuration(payload(tool_choice="required"))
|
| 58 |
+
assert required.must_call and required.required_name is None
|
| 59 |
+
specific = parse_tool_configuration(
|
| 60 |
+
payload(tool_choice={"type": "function", "function": {"name": "read_file"}})
|
| 61 |
+
)
|
| 62 |
+
assert specific.choice == "specific"
|
| 63 |
+
assert specific.required_name == "read_file"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_none_disables_adapter():
|
| 67 |
+
config = parse_tool_configuration(payload(tool_choice="none"))
|
| 68 |
+
assert not config.enabled
|
| 69 |
+
assert build_tool_instruction(config) == ""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_legacy_functions_and_function_call():
|
| 73 |
+
fn = payload()["tools"][0]["function"]
|
| 74 |
+
config = parse_tool_configuration({"functions": [fn], "function_call": "read_file"})
|
| 75 |
+
assert config.required_name == "read_file"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.parametrize(
|
| 79 |
+
"bad,match",
|
| 80 |
+
[
|
| 81 |
+
({"tools": "bad"}, "array"),
|
| 82 |
+
({"tools": [{"type": "web_search"}]}, "Only tools"),
|
| 83 |
+
({"tools": [{"type": "function", "function": {"name": "bad name"}}]}, "names"),
|
| 84 |
+
(payload(tool_choice="missing"), "tool_choice"),
|
| 85 |
+
({"tools": [], "tool_choice": "required"}, "at least one"),
|
| 86 |
+
],
|
| 87 |
+
)
|
| 88 |
+
def test_invalid_tool_configuration(bad, match):
|
| 89 |
+
with pytest.raises(ToolProtocolError, match=match):
|
| 90 |
+
parse_tool_configuration(bad)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_normalize_developer_assistant_calls_and_tool_results():
|
| 94 |
+
config = parse_tool_configuration(payload())
|
| 95 |
+
messages = normalize_messages(
|
| 96 |
+
[
|
| 97 |
+
{"role": "system", "content": "system"},
|
| 98 |
+
{"role": "developer", "content": "developer"},
|
| 99 |
+
{"role": "user", "content": [{"type": "text", "text": "open it"}]},
|
| 100 |
+
{
|
| 101 |
+
"role": "assistant",
|
| 102 |
+
"content": None,
|
| 103 |
+
"tool_calls": [
|
| 104 |
+
{
|
| 105 |
+
"id": "call_old",
|
| 106 |
+
"type": "function",
|
| 107 |
+
"function": {
|
| 108 |
+
"name": "read_file",
|
| 109 |
+
"arguments": '{"path":"a.py"}',
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
],
|
| 113 |
+
},
|
| 114 |
+
{"role": "tool", "tool_call_id": "call_old", "content": "print('ok')"},
|
| 115 |
+
],
|
| 116 |
+
build_tool_instruction(config),
|
| 117 |
+
)
|
| 118 |
+
assert messages[0]["role"] == "system"
|
| 119 |
+
assert "[DEVELOPER]" in messages[0]["content"]
|
| 120 |
+
assert "TOOL GATEWAY PROTOCOL" in messages[0]["content"]
|
| 121 |
+
assert "<|tool_call:name|>read_file" in messages[2]["content"]
|
| 122 |
+
assert messages[3]["role"] == "user"
|
| 123 |
+
assert "function: read_file" in messages[3]["content"]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_normalize_rejects_image_content():
|
| 127 |
+
with pytest.raises(ToolProtocolError, match="not supported"):
|
| 128 |
+
normalize_messages(
|
| 129 |
+
[
|
| 130 |
+
{
|
| 131 |
+
"role": "user",
|
| 132 |
+
"content": [
|
| 133 |
+
{"type": "text", "text": "look"},
|
| 134 |
+
{"type": "image_url", "image_url": {"url": "x"}},
|
| 135 |
+
],
|
| 136 |
+
}
|
| 137 |
+
]
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_parse_native_tool_call_and_schema_validate():
|
| 142 |
+
config = parse_tool_configuration(payload(tool_choice="required"))
|
| 143 |
+
calls = parse_generated_tool_calls(native(), config)
|
| 144 |
+
assert len(calls) == 1
|
| 145 |
+
assert calls[0].name == "read_file"
|
| 146 |
+
assert calls[0].arguments == {"path": "src/app.py"}
|
| 147 |
+
dumped = calls[0].as_openai()
|
| 148 |
+
assert dumped["id"].startswith("call_")
|
| 149 |
+
assert json.loads(dumped["function"]["arguments"])["path"] == "src/app.py"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_parse_multiple_native_calls():
|
| 153 |
+
tools = payload()["tools"] + [
|
| 154 |
+
{
|
| 155 |
+
"type": "function",
|
| 156 |
+
"function": {
|
| 157 |
+
"name": "write_file",
|
| 158 |
+
"parameters": {
|
| 159 |
+
"type": "object",
|
| 160 |
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
| 161 |
+
"required": ["path", "content"],
|
| 162 |
+
},
|
| 163 |
+
},
|
| 164 |
+
}
|
| 165 |
+
]
|
| 166 |
+
config = parse_tool_configuration({"tools": tools, "tool_choice": "required"})
|
| 167 |
+
text = native() + native("write_file", {"path": "b.py", "content": "x"}, "q2q")
|
| 168 |
+
calls = parse_generated_tool_calls(text, config)
|
| 169 |
+
assert [call.name for call in calls] == ["read_file", "write_file"]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_parallel_false_rejects_multiple():
|
| 173 |
+
tools = payload()["tools"] * 2
|
| 174 |
+
tools[1] = {"type": "function", "function": {"name": "other", "parameters": {"type": "object"}}}
|
| 175 |
+
config = parse_tool_configuration(
|
| 176 |
+
{"tools": tools, "tool_choice": "required", "parallel_tool_calls": False}
|
| 177 |
+
)
|
| 178 |
+
with pytest.raises(ToolProtocolError, match="multiple"):
|
| 179 |
+
parse_generated_tool_calls(native() + native("other", {}, "two"), config)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_parse_xml_and_raw_json_fallbacks():
|
| 183 |
+
config = parse_tool_configuration(payload())
|
| 184 |
+
xml = '<tool_calls>[{"name":"read_file","arguments":{"path":"x"}}]</tool_calls>'
|
| 185 |
+
assert parse_generated_tool_calls(xml, config)[0].arguments["path"] == "x"
|
| 186 |
+
raw = '{"name":"read_file","arguments":{"path":"y"}}'
|
| 187 |
+
assert parse_generated_tool_calls(raw, config)[0].arguments["path"] == "y"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_ordinary_json_is_not_misclassified():
|
| 191 |
+
config = parse_tool_configuration(payload())
|
| 192 |
+
assert parse_generated_tool_calls('{"answer":42}', config) == []
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_unknown_function_rejected():
|
| 196 |
+
config = parse_tool_configuration(payload())
|
| 197 |
+
with pytest.raises(ToolProtocolError, match="unknown"):
|
| 198 |
+
parse_generated_tool_calls(native("delete_everything", {"path": "x"}), config)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def test_forced_function_enforced():
|
| 202 |
+
config = parse_tool_configuration(
|
| 203 |
+
payload(tool_choice={"type": "function", "function": {"name": "read_file"}})
|
| 204 |
+
)
|
| 205 |
+
with pytest.raises(ToolProtocolError, match="was forced"):
|
| 206 |
+
parse_generated_tool_calls(native("other", {}), config)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_invalid_json_and_schema_rejected():
|
| 210 |
+
config = parse_tool_configuration(payload())
|
| 211 |
+
malformed = "<|tool_call:begin|>x<|tool_call:name|>read_file<|tool_call:args|>{bad}<|tool_call:end|>"
|
| 212 |
+
with pytest.raises(ToolProtocolError, match="not valid JSON"):
|
| 213 |
+
parse_generated_tool_calls(malformed, config)
|
| 214 |
+
with pytest.raises(ToolProtocolError, match="required property"):
|
| 215 |
+
parse_generated_tool_calls(native(arguments={"wrong": "x"}), config)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_prose_around_tool_call_rejected():
|
| 219 |
+
config = parse_tool_configuration(payload())
|
| 220 |
+
with pytest.raises(ToolProtocolError, match="prose outside"):
|
| 221 |
+
parse_generated_tool_calls("I will call it: " + native(), config)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_embedded_json_recovery_prefers_last_valid_call():
|
| 225 |
+
tools = payload()["tools"] + [
|
| 226 |
+
{
|
| 227 |
+
"type": "function",
|
| 228 |
+
"function": {
|
| 229 |
+
"name": "write_file",
|
| 230 |
+
"parameters": {
|
| 231 |
+
"type": "object",
|
| 232 |
+
"properties": {
|
| 233 |
+
"path": {"type": "string"},
|
| 234 |
+
"content": {"type": "string"},
|
| 235 |
+
},
|
| 236 |
+
"required": ["path", "content"],
|
| 237 |
+
},
|
| 238 |
+
},
|
| 239 |
+
}
|
| 240 |
+
]
|
| 241 |
+
config = parse_tool_configuration({"tools": tools})
|
| 242 |
+
narration = (
|
| 243 |
+
'For example {"name":"read_file","arguments":{"path":"old"}}. '
|
| 244 |
+
'I will now use {"name":"write_file","arguments":'
|
| 245 |
+
'{"path":"hello.py","content":"def hello():\\n return \\"world\\""}}'
|
| 246 |
+
)
|
| 247 |
+
calls = parse_embedded_tool_calls(narration, config)
|
| 248 |
+
assert len(calls) == 1 and calls[0].name == "write_file"
|
| 249 |
+
assert calls[0].arguments["path"] == "hello.py"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def test_repair_truncated_double_escaped_tool_json():
|
| 253 |
+
config = parse_tool_configuration(
|
| 254 |
+
{
|
| 255 |
+
"tools": [
|
| 256 |
+
{
|
| 257 |
+
"type": "function",
|
| 258 |
+
"function": {
|
| 259 |
+
"name": "write_file",
|
| 260 |
+
"parameters": {
|
| 261 |
+
"type": "object",
|
| 262 |
+
"properties": {
|
| 263 |
+
"path": {"type": "string"},
|
| 264 |
+
"content": {"type": "string"},
|
| 265 |
+
},
|
| 266 |
+
"required": ["path", "content"],
|
| 267 |
+
},
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
]
|
| 271 |
+
}
|
| 272 |
+
)
|
| 273 |
+
malformed = (
|
| 274 |
+
r'{"name":"write_file","arguments":{"path":"hello.py",'
|
| 275 |
+
r'"content":"def hello():\\n return \\"world\\""}'
|
| 276 |
+
)
|
| 277 |
+
calls = parse_generated_tool_calls(malformed, config)
|
| 278 |
+
assert calls[0].name == "write_file"
|
| 279 |
+
assert calls[0].arguments["path"] == "hello.py"
|
| 280 |
+
assert "world" in calls[0].arguments["content"]
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_incomplete_marker_rejected():
|
| 284 |
+
config = parse_tool_configuration(payload())
|
| 285 |
+
with pytest.raises(ToolProtocolError, match="incomplete"):
|
| 286 |
+
parse_generated_tool_calls("<|tool_call:begin|>oops", config)
|
tests/test_upstage_provider.py
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_upstage.py — v3 provider test suite
|
| 3 |
+
══════════════════════════════════════════════════════════════
|
| 4 |
+
Offline (always run): parser, splitter, sources, models, payload,
|
| 5 |
+
stream assembly (canned events), creds, usage.
|
| 6 |
+
Live (UPSTAGE_LIVE=1): real API — connect, realtime stream, search,
|
| 7 |
+
multi-turn, fresh credential capture.
|
| 8 |
+
|
| 9 |
+
Run: python3 -m pytest test_upstage.py -v
|
| 10 |
+
UPSTAGE_LIVE=1 python3 -m pytest test_upstage.py -k Live -v
|
| 11 |
+
══════════════════════════════════════════════════════════════
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import List, Tuple
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 26 |
+
|
| 27 |
+
from upstage_provider import ( # noqa: E402
|
| 28 |
+
UpstageProvider,
|
| 29 |
+
UpstageAuthError,
|
| 30 |
+
UpstageStreamError,
|
| 31 |
+
_SSE,
|
| 32 |
+
_Sources,
|
| 33 |
+
_find_action_id,
|
| 34 |
+
_Creds,
|
| 35 |
+
_resolve_model,
|
| 36 |
+
_MODELS,
|
| 37 |
+
ThinkSplitter,
|
| 38 |
+
StreamEvent,
|
| 39 |
+
TurnUsage,
|
| 40 |
+
SessionUsage,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# think tags built from fragments (literal tags get mangled by file tools)
|
| 44 |
+
_OPEN = "<" + "think" + ">"
|
| 45 |
+
_CLOSE = "</" + "think" + ">"
|
| 46 |
+
|
| 47 |
+
LIVE = os.environ.get("UPSTAGE_LIVE") == "1"
|
| 48 |
+
live = pytest.mark.skipif(not LIVE, reason="set UPSTAGE_LIVE=1 for live tests")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def run(coro):
|
| 52 |
+
return asyncio.run(coro)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def make_provider(**kw) -> UpstageProvider:
|
| 56 |
+
up = UpstageProvider(**kw)
|
| 57 |
+
up._connected = True # skip credential pipeline in unit tests
|
| 58 |
+
return up
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def canned(events: List[Tuple[str, str]]):
|
| 62 |
+
"""A fake _stream_events: assign as up._stream_events = canned([...])."""
|
| 63 |
+
async def fake(payload):
|
| 64 |
+
for ev in events:
|
| 65 |
+
yield ev
|
| 66 |
+
return fake
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ═══════════════════════════════════════════════════════════
|
| 70 |
+
# SSE PARSER
|
| 71 |
+
# ═══════════════════════════════════════════════════════════
|
| 72 |
+
class TestSSE:
|
| 73 |
+
|
| 74 |
+
def test_content_delta(self):
|
| 75 |
+
line = 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}'
|
| 76 |
+
assert _SSE.parse_line(line) == [("t-delta", "hi")]
|
| 77 |
+
|
| 78 |
+
def test_reasoning_delta(self):
|
| 79 |
+
line = 'data: {"choices":[{"delta":{"reasoning_content":"hmm"},"finish_reason":null}]}'
|
| 80 |
+
assert _SSE.parse_line(line) == [("r-delta", "hmm")]
|
| 81 |
+
|
| 82 |
+
def test_done_sentinel(self):
|
| 83 |
+
assert _SSE.parse_line("data: [DONE]") == [("done", "")]
|
| 84 |
+
|
| 85 |
+
def test_finish_stop(self):
|
| 86 |
+
line = 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}'
|
| 87 |
+
assert _SSE.parse_line(line) == [("done", "")]
|
| 88 |
+
|
| 89 |
+
def test_empty_delta_skipped(self):
|
| 90 |
+
line = 'data: {"choices":[{"delta":{"content":""},"finish_reason":null}]}'
|
| 91 |
+
assert _SSE.parse_line(line) == []
|
| 92 |
+
|
| 93 |
+
def test_garbage(self):
|
| 94 |
+
assert _SSE.parse_line("") == []
|
| 95 |
+
assert _SSE.parse_line("event: ping") == []
|
| 96 |
+
assert _SSE.parse_line("data: {not json") == []
|
| 97 |
+
|
| 98 |
+
def test_search_start(self):
|
| 99 |
+
line = ('data: {"search":{"status":{"action":"search_start","description":"d"},'
|
| 100 |
+
'"search_queries":[{"query":"q1","results":[]}]}}')
|
| 101 |
+
assert _SSE.parse_line(line) == [("s-start", "q1")]
|
| 102 |
+
|
| 103 |
+
def test_search_finish(self):
|
| 104 |
+
sq = [{"query": "q1", "results": [{"url": "u", "title": "t"}]}]
|
| 105 |
+
obj = {"search": {"status": {"action": "search_finish", "description": "d"},
|
| 106 |
+
"search_queries": sq}}
|
| 107 |
+
line = "data: " + json.dumps(obj)
|
| 108 |
+
evs = _SSE.parse_line(line)
|
| 109 |
+
assert evs == [("source", json.dumps(sq))]
|
| 110 |
+
|
| 111 |
+
def test_summarizing(self):
|
| 112 |
+
line = ('data: {"search":{"status":{"action":"summarizing","description":"sum"},'
|
| 113 |
+
'"search_queries":null}}')
|
| 114 |
+
assert _SSE.parse_line(line) == [("s-summary", "sum")]
|
| 115 |
+
|
| 116 |
+
def test_usage_zero_ignored(self):
|
| 117 |
+
line = ('data: {"choices":[{"delta":{"content":"x"},"finish_reason":null}],'
|
| 118 |
+
'"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}')
|
| 119 |
+
assert _SSE.parse_line(line) == [("t-delta", "x")]
|
| 120 |
+
|
| 121 |
+
def test_usage_nonzero_emitted(self):
|
| 122 |
+
line = ('data: {"choices":[{"delta":{"content":"x"},"finish_reason":null}],'
|
| 123 |
+
'"usage":{"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}}')
|
| 124 |
+
evs = _SSE.parse_line(line)
|
| 125 |
+
assert evs[0] == ("t-delta", "x")
|
| 126 |
+
assert evs[1][0] == "usage"
|
| 127 |
+
assert json.loads(evs[1][1])["total_tokens"] == 12
|
| 128 |
+
|
| 129 |
+
def test_usage_with_stop_done_last(self):
|
| 130 |
+
line = ('data: {"choices":[{"delta":{},"finish_reason":"stop"}],'
|
| 131 |
+
'"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}')
|
| 132 |
+
evs = _SSE.parse_line(line)
|
| 133 |
+
assert [e[0] for e in evs] == ["usage", "done"]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ═══════════════════════════════════════════════════════════
|
| 137 |
+
# ACTION-ID EXTRACTION
|
| 138 |
+
# ═══════════════════════════════════════════════════════════
|
| 139 |
+
class TestFindActionId:
|
| 140 |
+
A1 = "a" * 42
|
| 141 |
+
A2 = "b" * 42
|
| 142 |
+
|
| 143 |
+
def test_simple(self):
|
| 144 |
+
js = f'createServerReference)("{self.A1}",x.callServer,void 0,x.findSourceMapURL,"getConsoleCsrfToken")'
|
| 145 |
+
assert _find_action_id(js, "getConsoleCsrfToken") == self.A1
|
| 146 |
+
|
| 147 |
+
def test_cross_wiring_guard(self):
|
| 148 |
+
"""Two actions on one line — id must stay pinned to its own name."""
|
| 149 |
+
js = (f'createServerReference)("{self.A1}",x.callServer,void 0,x.findSourceMapURL,"authAction"),'
|
| 150 |
+
f'createServerReference)("{self.A2}",x.callServer,void 0,x.findSourceMapURL,"getConsoleCsrfToken")')
|
| 151 |
+
assert _find_action_id(js, "getConsoleCsrfToken") == self.A2
|
| 152 |
+
assert _find_action_id(js, "authAction") == self.A1
|
| 153 |
+
|
| 154 |
+
def test_missing(self):
|
| 155 |
+
assert _find_action_id("nothing here", "getConsoleCsrfToken") is None
|
| 156 |
+
|
| 157 |
+
def test_id_length_flexible(self):
|
| 158 |
+
short = "c" * 40
|
| 159 |
+
js = f'createServerReference)("{short}",x.callServer,void 0,x.findSourceMapURL,"authAction")'
|
| 160 |
+
assert _find_action_id(js, "authAction") == short
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ═══════════════════════════════════════════════════════════
|
| 164 |
+
# THINK SPLITTER
|
| 165 |
+
# ═══════════════════════════════════════════════════════════
|
| 166 |
+
class TestThinkSplitter:
|
| 167 |
+
|
| 168 |
+
def test_plain_content_holds_partial(self):
|
| 169 |
+
sp = ThinkSplitter()
|
| 170 |
+
assert sp.feed("ab") == [] # held: possible partial tag
|
| 171 |
+
assert sp.feed("let me ") == [("content", "abl")]
|
| 172 |
+
assert sp.feed("ok") == [("content", "et")]
|
| 173 |
+
assert sp.flush() == [("content", " me ok")]
|
| 174 |
+
|
| 175 |
+
def test_full_block_one_feed(self):
|
| 176 |
+
sp = ThinkSplitter()
|
| 177 |
+
# trailing "cd" is held back as a possible partial tag → flush()
|
| 178 |
+
out = sp.feed(f"ab{_OPEN}R1{_CLOSE}cd")
|
| 179 |
+
assert out == [("content", "ab"), ("thinking", "R1")]
|
| 180 |
+
assert sp.flush() == [("content", "cd")]
|
| 181 |
+
|
| 182 |
+
def test_open_tag_split_across_feeds(self):
|
| 183 |
+
sp = ThinkSplitter()
|
| 184 |
+
assert sp.feed("a<th") == [] # partial open held
|
| 185 |
+
assert sp.feed("ink>R") == [("content", "a")] # open tag completes
|
| 186 |
+
assert sp.feed("reason") == [] # short: all held back
|
| 187 |
+
out = sp.feed(f"more{_CLOSE}after")
|
| 188 |
+
assert out == [("thinking", "Rreasonmore")]
|
| 189 |
+
assert sp.flush() == [("content", "after")]
|
| 190 |
+
|
| 191 |
+
def test_close_tag_split_across_feeds(self):
|
| 192 |
+
sp = ThinkSplitter()
|
| 193 |
+
sp.feed(f"{_OPEN}R")
|
| 194 |
+
sp.feed("1")
|
| 195 |
+
out = sp.feed(f"</thi" + "nk>done")
|
| 196 |
+
assert out == [("thinking", "R1")]
|
| 197 |
+
assert sp.flush() == [("content", "done")]
|
| 198 |
+
|
| 199 |
+
def test_ended_inside_think(self):
|
| 200 |
+
sp = ThinkSplitter()
|
| 201 |
+
out = sp.feed(f"{_OPEN}never closed")
|
| 202 |
+
assert out == [("thinking", "never")]
|
| 203 |
+
assert sp.flush() == [("thinking", " closed")]
|
| 204 |
+
|
| 205 |
+
def test_multiple_blocks(self):
|
| 206 |
+
sp = ThinkSplitter()
|
| 207 |
+
out = sp.feed(f"{_OPEN}A{_CLOSE}mid{_OPEN}B{_CLOSE}end")
|
| 208 |
+
assert out == [("thinking", "A"), ("content", "mid"), ("thinking", "B")]
|
| 209 |
+
assert sp.flush() == [("content", "end")]
|
| 210 |
+
|
| 211 |
+
def test_empty_feed_and_flush(self):
|
| 212 |
+
sp = ThinkSplitter()
|
| 213 |
+
assert sp.feed("") == []
|
| 214 |
+
assert sp.flush() == []
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ═══════════════════════════════════════════════════════════
|
| 218 |
+
# SOURCES
|
| 219 |
+
# ═══════════════════════════════════════════════════════════
|
| 220 |
+
class TestSources:
|
| 221 |
+
RAW = json.dumps([{
|
| 222 |
+
"query": "q",
|
| 223 |
+
"results": [
|
| 224 |
+
{"url": "https://a.com", "title": "A", "score": 0.9,
|
| 225 |
+
"content": "x" * 300},
|
| 226 |
+
{"url": "https://a.com", "title": "A-dup", "score": 0.5,
|
| 227 |
+
"content": "dup"},
|
| 228 |
+
{"url": "https://b.com", "title": "", "score": 0.7,
|
| 229 |
+
"content": "B content"},
|
| 230 |
+
{"url": "", "title": "no-url", "score": 0.1, "content": "skip"},
|
| 231 |
+
],
|
| 232 |
+
}])
|
| 233 |
+
|
| 234 |
+
def test_parse_dedup_and_index(self):
|
| 235 |
+
got = _Sources.parse([self.RAW])
|
| 236 |
+
assert [s["url"] for s in got] == ["https://a.com", "https://b.com"]
|
| 237 |
+
assert [s["index"] for s in got] == [1, 2]
|
| 238 |
+
assert got[0]["query"] == "q"
|
| 239 |
+
assert got[1]["title"] == "Untitled"
|
| 240 |
+
|
| 241 |
+
def test_parse_snippet_truncation(self):
|
| 242 |
+
got = _Sources.parse([self.RAW])
|
| 243 |
+
assert len(got[0]["snippet"]) <= 203
|
| 244 |
+
assert got[0]["snippet"].endswith("...")
|
| 245 |
+
|
| 246 |
+
def test_parse_bad_input(self):
|
| 247 |
+
assert _Sources.parse(["not json", "null", "[]"]) == []
|
| 248 |
+
|
| 249 |
+
def test_format_text(self):
|
| 250 |
+
got = _Sources.parse([self.RAW])
|
| 251 |
+
txt = _Sources.format_text(got)
|
| 252 |
+
assert "📚 Sources (2)" in txt
|
| 253 |
+
assert "https://a.com" in txt and "https://b.com" in txt
|
| 254 |
+
assert "Score: 0.9000" in txt
|
| 255 |
+
assert _Sources.format_text([]) == ""
|
| 256 |
+
|
| 257 |
+
def test_format_json(self):
|
| 258 |
+
got = _Sources.parse([self.RAW])
|
| 259 |
+
blob = json.loads(_Sources.format_json(got))
|
| 260 |
+
assert len(blob["sources"]) == 2
|
| 261 |
+
assert set(blob["sources"][0]) == {"title", "url", "score"}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ═══════════════════════════════════════════════════════════
|
| 265 |
+
# MODEL RESOLUTION
|
| 266 |
+
# ═══════════════════════════════════════════════════════════
|
| 267 |
+
class TestModels:
|
| 268 |
+
|
| 269 |
+
def test_alias(self):
|
| 270 |
+
assert _resolve_model("pro2") == "solar-pro2"
|
| 271 |
+
assert _resolve_model("mini") == "upstage/solar-1-mini-chat"
|
| 272 |
+
assert _resolve_model("syn") == "syn-pro"
|
| 273 |
+
|
| 274 |
+
def test_fuzzy(self):
|
| 275 |
+
assert _resolve_model("solar-pro") == "solar-pro3" # first match wins
|
| 276 |
+
|
| 277 |
+
def test_unknown_passthrough(self):
|
| 278 |
+
assert _resolve_model("custom/model") == "custom/model"
|
| 279 |
+
|
| 280 |
+
def test_empty_default(self):
|
| 281 |
+
assert _resolve_model("") == "solar-pro3"
|
| 282 |
+
|
| 283 |
+
def test_list_models(self):
|
| 284 |
+
up = make_provider()
|
| 285 |
+
models = up.list_models()
|
| 286 |
+
assert len(models) == 4
|
| 287 |
+
assert [m for m in models if m["active"]] == [m for m in models if m["name"] == "solar-pro3"]
|
| 288 |
+
|
| 289 |
+
def test_model_info(self):
|
| 290 |
+
info = UpstageProvider.model_info()
|
| 291 |
+
assert info["thinking"] is True and info["search"] is True
|
| 292 |
+
assert set(info["models"]) == set(_MODELS)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ═══════════════════════════════════════════════════════════
|
| 296 |
+
# PAYLOAD BUILDER
|
| 297 |
+
# ═══════════════════════════════════════════════════════════
|
| 298 |
+
class TestPayload:
|
| 299 |
+
|
| 300 |
+
def _p(self, up, search=False, reasoning=None):
|
| 301 |
+
return up._build_payload(
|
| 302 |
+
[{"role": "user", "content": "hi"}], up.model,
|
| 303 |
+
search, reasoning, None, None)
|
| 304 |
+
|
| 305 |
+
def test_basic(self):
|
| 306 |
+
up = make_provider()
|
| 307 |
+
p = self._p(up)
|
| 308 |
+
assert p["stream"] is True and p["model"] == "solar-pro3"
|
| 309 |
+
assert p["messages"] == [{"role": "user", "content": "hi"}]
|
| 310 |
+
assert "conversation_id" in p
|
| 311 |
+
|
| 312 |
+
def test_search_mode_on_last_user(self):
|
| 313 |
+
up = make_provider()
|
| 314 |
+
p = self._p(up, search=True)
|
| 315 |
+
assert p["messages"][-1]["mode"] == ["search"]
|
| 316 |
+
assert p["search_provider"] == "tavily"
|
| 317 |
+
assert p["reasoning_effort"] == "high" # auto: search → high
|
| 318 |
+
|
| 319 |
+
def test_no_search_low_reasoning(self):
|
| 320 |
+
up = make_provider()
|
| 321 |
+
p = self._p(up, search=False)
|
| 322 |
+
assert "mode" not in p["messages"][-1]
|
| 323 |
+
assert "search_provider" not in p
|
| 324 |
+
assert p["reasoning_effort"] == "low" # auto: no search → low
|
| 325 |
+
|
| 326 |
+
def test_explicit_reasoning_wins(self):
|
| 327 |
+
up = make_provider()
|
| 328 |
+
assert self._p(up, search=True, reasoning="medium")["reasoning_effort"] == "medium"
|
| 329 |
+
assert self._p(up, search=False, reasoning="high")["reasoning_effort"] == "high"
|
| 330 |
+
|
| 331 |
+
def test_invalid_reasoning_ignored(self):
|
| 332 |
+
up = make_provider()
|
| 333 |
+
assert self._p(up, search=False, reasoning="banana")["reasoning_effort"] == "low"
|
| 334 |
+
|
| 335 |
+
def test_min_model_has_no_reasoning(self):
|
| 336 |
+
up = make_provider()
|
| 337 |
+
p = up._build_payload([{"role": "user", "content": "hi"}],
|
| 338 |
+
"upstage/solar-1-mini-chat", False, None, None, None)
|
| 339 |
+
assert "reasoning_effort" not in p
|
| 340 |
+
|
| 341 |
+
def test_system_injected(self):
|
| 342 |
+
up = make_provider(system="SYS")
|
| 343 |
+
p = self._p(up)
|
| 344 |
+
assert p["messages"][0] == {"role": "system", "content": "SYS"}
|
| 345 |
+
|
| 346 |
+
def test_syn_metadata(self):
|
| 347 |
+
up = make_provider()
|
| 348 |
+
p = up._build_payload([{"role": "user", "content": "hi"}],
|
| 349 |
+
"syn-pro", False, None, None, None)
|
| 350 |
+
assert p["metadata"]["quality"] == 4
|
| 351 |
+
|
| 352 |
+
def test_input_not_mutated(self):
|
| 353 |
+
up = make_provider()
|
| 354 |
+
msgs = [{"role": "user", "content": "hi"}]
|
| 355 |
+
up._build_payload(msgs, "solar-pro3", True, None, None, None)
|
| 356 |
+
assert msgs == [{"role": "user", "content": "hi"}] # no mode added in-place
|
| 357 |
+
|
| 358 |
+
def test_overrides(self):
|
| 359 |
+
up = make_provider(temperature=0.1, max_tokens=55)
|
| 360 |
+
p = up._build_payload([{"role": "user", "content": "hi"}],
|
| 361 |
+
"solar-pro3", False, None, None, None)
|
| 362 |
+
assert p["temperature"] == 0.1 and p["max_tokens"] == 55
|
| 363 |
+
p2 = up._build_payload([{"role": "user", "content": "hi"}],
|
| 364 |
+
"solar-pro3", False, None, 0.9, 77)
|
| 365 |
+
assert p2["temperature"] == 0.9 and p2["max_tokens"] == 77
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# ═══════════════════════════════════════════════════════════
|
| 369 |
+
# STREAM ASSEMBLY (canned events, no network)
|
| 370 |
+
# ═══════════════════════════════════════════════════════════
|
| 371 |
+
class TestStreamAssembly:
|
| 372 |
+
|
| 373 |
+
def test_data_mode_basic(self):
|
| 374 |
+
up = make_provider()
|
| 375 |
+
up._stream_events = canned([("t-delta", "Hel"), ("t-delta", "lo"),
|
| 376 |
+
("done", "")])
|
| 377 |
+
|
| 378 |
+
async def go():
|
| 379 |
+
evs = [ev async for ev in up.stream(data="hi")]
|
| 380 |
+
return evs
|
| 381 |
+
evs = run(go())
|
| 382 |
+
# short tokens are held back by the splitter → one merged flush event
|
| 383 |
+
assert [e.kind for e in evs] == ["content", "done"]
|
| 384 |
+
assert up.last_response == "Hello"
|
| 385 |
+
assert up.last_reasoning == ""
|
| 386 |
+
roles = [m["role"] for m in up.history]
|
| 387 |
+
assert roles == ["user", "assistant"]
|
| 388 |
+
|
| 389 |
+
def test_messages_mode_replaces_history(self):
|
| 390 |
+
up = make_provider()
|
| 391 |
+
up._stream_events = canned([("t-delta", "Bob here"), ("done", "")])
|
| 392 |
+
msgs = [
|
| 393 |
+
{"role": "system", "content": "You are Bob."},
|
| 394 |
+
{"role": "user", "content": "Name?"},
|
| 395 |
+
]
|
| 396 |
+
|
| 397 |
+
async def go():
|
| 398 |
+
return [ev async for ev in up.stream(messages=msgs)]
|
| 399 |
+
run(go())
|
| 400 |
+
assert up.last_response == "Bob here"
|
| 401 |
+
# messages mode: per-call system is sent but not kept in history
|
| 402 |
+
assert [m["role"] for m in up.history] == ["user", "assistant"]
|
| 403 |
+
assert all(m["role"] != "system" for m in up.history)
|
| 404 |
+
|
| 405 |
+
def test_r_delta_is_thinking(self):
|
| 406 |
+
up = make_provider()
|
| 407 |
+
up._stream_events = canned([("r-delta", "hmm"), ("r-delta", "…"),
|
| 408 |
+
("t-delta", "yes"), ("done", "")])
|
| 409 |
+
|
| 410 |
+
async def go():
|
| 411 |
+
return [ev async for ev in up.stream(data="q")]
|
| 412 |
+
evs = run(go())
|
| 413 |
+
assert [e.kind for e in evs] == ["thinking", "thinking", "content", "done"]
|
| 414 |
+
assert up.last_reasoning == "hmm…"
|
| 415 |
+
assert up.last_response == "yes"
|
| 416 |
+
|
| 417 |
+
def test_inline_think_splitting(self):
|
| 418 |
+
up = make_provider()
|
| 419 |
+
feed = [f"ab{_OPEN}R", f"1{_CLOSE}cd"] # tags split across tokens
|
| 420 |
+
up._stream_events = canned([("t-delta", feed[0]),
|
| 421 |
+
("t-delta", feed[1]), ("done", "")])
|
| 422 |
+
|
| 423 |
+
async def go():
|
| 424 |
+
return [ev async for ev in up.stream(data="q")]
|
| 425 |
+
evs = run(go())
|
| 426 |
+
assert "".join(e.text for e in evs if e.kind == "content") == "abcd"
|
| 427 |
+
assert "".join(e.text for e in evs if e.kind == "thinking") == "R1"
|
| 428 |
+
assert up.last_response == "abcd"
|
| 429 |
+
assert up.last_reasoning == "R1"
|
| 430 |
+
|
| 431 |
+
def test_think_markup_never_leaks(self):
|
| 432 |
+
up = make_provider()
|
| 433 |
+
up._stream_events = canned([
|
| 434 |
+
("t-delta", f"x{_OPEN}A{_CLOSE}y"), ("done", "")])
|
| 435 |
+
|
| 436 |
+
async def go():
|
| 437 |
+
return [ev async for ev in up.stream(data="q")]
|
| 438 |
+
run(go())
|
| 439 |
+
assert _OPEN not in up.last_response and _CLOSE not in up.last_response
|
| 440 |
+
assert _OPEN not in up.last_reasoning and _CLOSE not in up.last_reasoning
|
| 441 |
+
|
| 442 |
+
def test_sources_yielded_first(self):
|
| 443 |
+
up = make_provider()
|
| 444 |
+
sq = [{"query": "q", "results": [{"url": "https://x.com", "title": "X"}]}]
|
| 445 |
+
up._stream_events = canned([
|
| 446 |
+
("s-start", "q"),
|
| 447 |
+
("source", json.dumps(sq)),
|
| 448 |
+
("s-summary", "sum"),
|
| 449 |
+
("t-delta", "Paris"), ("done", "")])
|
| 450 |
+
|
| 451 |
+
async def go():
|
| 452 |
+
return [ev async for ev in up.stream(data="cap of france?", search=True)]
|
| 453 |
+
evs = run(go())
|
| 454 |
+
assert evs[0].kind == "sources"
|
| 455 |
+
blob = json.loads(evs[0].text)
|
| 456 |
+
assert blob["sources"][0]["url"] == "https://x.com"
|
| 457 |
+
assert up.last_sources and up.last_sources[0]["url"] == "https://x.com"
|
| 458 |
+
assert "📚 Sources (1)" in up.last_sources_text
|
| 459 |
+
assert evs[-1].kind == "done"
|
| 460 |
+
|
| 461 |
+
def test_usage_event_captured(self):
|
| 462 |
+
up = make_provider()
|
| 463 |
+
up._stream_events = canned([
|
| 464 |
+
("t-delta", "x"),
|
| 465 |
+
("usage", json.dumps({"prompt_tokens": 5,
|
| 466 |
+
"completion_tokens": 3,
|
| 467 |
+
"total_tokens": 8})),
|
| 468 |
+
("done", "")])
|
| 469 |
+
|
| 470 |
+
async def go():
|
| 471 |
+
return [ev async for ev in up.stream(data="q")]
|
| 472 |
+
run(go())
|
| 473 |
+
assert up.last_usage.api_usage["total_tokens"] == 8
|
| 474 |
+
assert up.last_usage.tokens == 8
|
| 475 |
+
assert up.last_usage.tokens_estimated is False
|
| 476 |
+
|
| 477 |
+
def test_last_usage_fields(self):
|
| 478 |
+
up = make_provider()
|
| 479 |
+
up._stream_events = canned([("t-delta", "hello world"), ("done", "")])
|
| 480 |
+
|
| 481 |
+
async def go():
|
| 482 |
+
return [ev async for ev in up.stream(data="q")]
|
| 483 |
+
run(go())
|
| 484 |
+
u = up.last_usage
|
| 485 |
+
assert u.ok is True and u.error == ""
|
| 486 |
+
assert u.model == "solar-pro3"
|
| 487 |
+
assert u.content_chars == len("hello world")
|
| 488 |
+
assert u.prompt_chars > 0
|
| 489 |
+
assert u.first_token_s is not None and u.first_token_s >= 0
|
| 490 |
+
assert u.elapsed_s >= u.first_token_s
|
| 491 |
+
assert u.tokens > 0 and u.tokens_estimated is True
|
| 492 |
+
|
| 493 |
+
def test_session_usage_accumulates(self):
|
| 494 |
+
up = make_provider()
|
| 495 |
+
up._stream_events = canned([("t-delta", "a"), ("done", "")])
|
| 496 |
+
|
| 497 |
+
async def go():
|
| 498 |
+
async for _ in up.stream(data="1"):
|
| 499 |
+
pass
|
| 500 |
+
async for _ in up.stream(data="2"):
|
| 501 |
+
pass
|
| 502 |
+
run(go())
|
| 503 |
+
tt = up.session_usage.totals()
|
| 504 |
+
assert tt["turns"] == 2 and tt["ok"] == 2 and tt["failed"] == 0
|
| 505 |
+
assert "TOTAL" in up.session_usage.format_report()
|
| 506 |
+
|
| 507 |
+
up.new_session()
|
| 508 |
+
assert up.session_usage.totals()["turns"] == 0
|
| 509 |
+
assert up.history == [] and up.last_response == ""
|
| 510 |
+
assert up.last_usage is None
|
| 511 |
+
|
| 512 |
+
def test_early_break_finalizes_state(self):
|
| 513 |
+
up = make_provider()
|
| 514 |
+
up._stream_events = canned([("t-delta", "ab"), ("t-delta", "cd"),
|
| 515 |
+
("done", "")])
|
| 516 |
+
|
| 517 |
+
async def go():
|
| 518 |
+
n = 0
|
| 519 |
+
async for ev in up.stream(data="q"):
|
| 520 |
+
n += 1
|
| 521 |
+
if n == 1:
|
| 522 |
+
break # consumer abandons mid-stream
|
| 523 |
+
return n
|
| 524 |
+
assert run(go()) == 1
|
| 525 |
+
# state still consistent (both deltas were buffered before the
|
| 526 |
+
# first yield, so all of it is in last_response)
|
| 527 |
+
assert up.last_response == "abcd"
|
| 528 |
+
assert up.last_usage.ok is False
|
| 529 |
+
assert "interrupted" in up.last_usage.error
|
| 530 |
+
assert [m["role"] for m in up.history] == ["user"] # no assistant appended
|
| 531 |
+
|
| 532 |
+
def test_auth_retry_succeeds(self):
|
| 533 |
+
up = make_provider()
|
| 534 |
+
calls = {"stream": 0, "capture": 0}
|
| 535 |
+
events = [("t-delta", "ok"), ("done", "")]
|
| 536 |
+
|
| 537 |
+
async def fake_stream(payload):
|
| 538 |
+
calls["stream"] += 1
|
| 539 |
+
if calls["stream"] == 1:
|
| 540 |
+
raise UpstageAuthError("401")
|
| 541 |
+
for ev in events:
|
| 542 |
+
yield ev
|
| 543 |
+
|
| 544 |
+
async def fake_capture():
|
| 545 |
+
calls["capture"] += 1
|
| 546 |
+
|
| 547 |
+
up._stream_events = fake_stream
|
| 548 |
+
up._creds.capture = fake_capture
|
| 549 |
+
|
| 550 |
+
async def go():
|
| 551 |
+
return [ev async for ev in up.stream(data="q")]
|
| 552 |
+
evs = run(go())
|
| 553 |
+
assert [e.kind for e in evs] == ["content", "done"]
|
| 554 |
+
assert calls["stream"] == 2 and calls["capture"] == 1
|
| 555 |
+
assert up.last_response == "ok"
|
| 556 |
+
|
| 557 |
+
def test_auth_failure_twice_raises(self):
|
| 558 |
+
up = make_provider()
|
| 559 |
+
calls = {"capture": 0}
|
| 560 |
+
|
| 561 |
+
async def fake_stream(payload):
|
| 562 |
+
raise UpstageAuthError("403")
|
| 563 |
+
yield # pragma: no cover (async-gen marker)
|
| 564 |
+
|
| 565 |
+
async def fake_capture():
|
| 566 |
+
calls["capture"] += 1
|
| 567 |
+
|
| 568 |
+
up._stream_events = fake_stream
|
| 569 |
+
up._creds.capture = fake_capture
|
| 570 |
+
|
| 571 |
+
async def go():
|
| 572 |
+
async for _ in up.stream(data="q"):
|
| 573 |
+
pass
|
| 574 |
+
with pytest.raises(UpstageAuthError):
|
| 575 |
+
run(go())
|
| 576 |
+
assert calls["capture"] == 1 # one re-capture, then give up
|
| 577 |
+
assert up.last_usage.ok is False
|
| 578 |
+
assert "auth" in up.last_usage.error
|
| 579 |
+
|
| 580 |
+
def test_generic_error_wrapped(self):
|
| 581 |
+
up = make_provider()
|
| 582 |
+
|
| 583 |
+
async def fake_stream(payload):
|
| 584 |
+
raise ConnectionError("socket blew up")
|
| 585 |
+
yield # pragma: no cover (async gen marker)
|
| 586 |
+
|
| 587 |
+
up._stream_events = fake_stream
|
| 588 |
+
|
| 589 |
+
async def go():
|
| 590 |
+
async for _ in up.stream(data="q"):
|
| 591 |
+
pass
|
| 592 |
+
with pytest.raises(UpstageStreamError):
|
| 593 |
+
run(go())
|
| 594 |
+
assert up.last_usage.ok is False
|
| 595 |
+
assert "socket blew up" in up.last_usage.error
|
| 596 |
+
|
| 597 |
+
def test_chat_plain_string_contract(self):
|
| 598 |
+
up = make_provider()
|
| 599 |
+
sq = [{"query": "q", "results": [{"url": "https://x.com", "title": "X"}]}]
|
| 600 |
+
up._stream_events = canned([("source", json.dumps(sq)),
|
| 601 |
+
("t-delta", "Paris"), ("done", "")])
|
| 602 |
+
|
| 603 |
+
async def go():
|
| 604 |
+
return [tok async for tok in up.chat(data="cap?", search=True)]
|
| 605 |
+
toks = run(go())
|
| 606 |
+
assert all(isinstance(t, str) for t in toks)
|
| 607 |
+
assert toks[0].startswith('{"sources"')
|
| 608 |
+
assert "".join(toks[1:]) == "Paris"
|
| 609 |
+
|
| 610 |
+
def test_requires_input(self):
|
| 611 |
+
up = make_provider()
|
| 612 |
+
|
| 613 |
+
async def go():
|
| 614 |
+
async for _ in up.stream():
|
| 615 |
+
pass
|
| 616 |
+
with pytest.raises(ValueError):
|
| 617 |
+
run(go())
|
| 618 |
+
|
| 619 |
+
def test_setters_chainable(self):
|
| 620 |
+
up = UpstageProvider()
|
| 621 |
+
out = (up.set_model("pro2").set_system("S").set_search(True)
|
| 622 |
+
.set_temperature(0.5).set_max_tokens(42))
|
| 623 |
+
assert out is up
|
| 624 |
+
assert (up.model, up.system, up.search,
|
| 625 |
+
up.temperature, up.max_tokens) == ("solar-pro2", "S", True, 0.5, 42)
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
# ═══════════════════════════════════════════════════════════
|
| 629 |
+
# CREDENTIALS (offline bits)
|
| 630 |
+
# ═══════════════════════════════════════════════════════════
|
| 631 |
+
class TestCreds:
|
| 632 |
+
|
| 633 |
+
def test_load_missing(self, tmp_path):
|
| 634 |
+
cr = _Creds(path=tmp_path / "nope.json")
|
| 635 |
+
|
| 636 |
+
async def go():
|
| 637 |
+
return await cr.load()
|
| 638 |
+
assert run(go()) is False
|
| 639 |
+
|
| 640 |
+
def test_save_load_roundtrip(self, tmp_path):
|
| 641 |
+
p = tmp_path / "sub" / "creds.json"
|
| 642 |
+
cr = _Creds(path=p)
|
| 643 |
+
cr.action_token = "a" * 42
|
| 644 |
+
cr.action_init = "b" * 42
|
| 645 |
+
cr.cookies = {"session_id": "s1", "other": "o"}
|
| 646 |
+
|
| 647 |
+
async def go():
|
| 648 |
+
await cr.save()
|
| 649 |
+
cr2 = _Creds(path=p)
|
| 650 |
+
ok = await cr2.load()
|
| 651 |
+
return ok, cr2
|
| 652 |
+
ok, cr2 = run(go())
|
| 653 |
+
assert ok is True
|
| 654 |
+
assert cr2.action_token == "a" * 42
|
| 655 |
+
assert cr2.cookies["session_id"] == "s1"
|
| 656 |
+
assert cr2.session_id == "s1"
|
| 657 |
+
|
| 658 |
+
def test_clear(self, tmp_path):
|
| 659 |
+
p = tmp_path / "c.json"
|
| 660 |
+
|
| 661 |
+
async def go():
|
| 662 |
+
cr = _Creds(path=p)
|
| 663 |
+
cr.action_token = "x"
|
| 664 |
+
await cr.save()
|
| 665 |
+
await cr.clear()
|
| 666 |
+
return p.exists()
|
| 667 |
+
assert run(go()) is False
|
| 668 |
+
|
| 669 |
+
def test_verify_without_action_token(self, tmp_path):
|
| 670 |
+
cr = _Creds(path=tmp_path / "c.json")
|
| 671 |
+
|
| 672 |
+
async def go():
|
| 673 |
+
return await cr.verify()
|
| 674 |
+
assert run(go()) is None
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
# ═══════════════════════════════════════════════════════════
|
| 678 |
+
# USAGE DEPARTMENT
|
| 679 |
+
# ═══════════════════════════════════════════════════════════
|
| 680 |
+
class TestUsage:
|
| 681 |
+
|
| 682 |
+
def test_estimate_tokens(self):
|
| 683 |
+
u = TurnUsage(model="m", thinking_chars=100, content_chars=100,
|
| 684 |
+
elapsed_s=2.0)
|
| 685 |
+
assert u.tokens == 50
|
| 686 |
+
assert u.tokens_estimated is True
|
| 687 |
+
assert u.tokens_per_s == 25.0
|
| 688 |
+
|
| 689 |
+
def test_api_tokens_preferred(self):
|
| 690 |
+
u = TurnUsage(model="m", thinking_chars=100, content_chars=100,
|
| 691 |
+
api_usage={"total_tokens": 77})
|
| 692 |
+
assert u.tokens == 77 and u.tokens_estimated is False
|
| 693 |
+
|
| 694 |
+
def test_format_line(self):
|
| 695 |
+
u = TurnUsage(model="solar-pro3", thinking_chars=120, content_chars=40,
|
| 696 |
+
elapsed_s=2.0, first_token_s=0.5, n_sources=2)
|
| 697 |
+
line = u.format_line()
|
| 698 |
+
for frag in ("⏱ 2.0s", "first token 0.50s", "~40 tok (est)",
|
| 699 |
+
"20 tok/s", "💭 120c", "solar-pro3", "📚 2"):
|
| 700 |
+
assert frag in line, line
|
| 701 |
+
|
| 702 |
+
def test_format_line_failed(self):
|
| 703 |
+
u = TurnUsage(model="m", ok=False, error="boom")
|
| 704 |
+
assert "✗" in u.format_line()
|
| 705 |
+
|
| 706 |
+
def test_session_report_empty(self):
|
| 707 |
+
assert "no turns yet" in SessionUsage().format_report()
|
| 708 |
+
|
| 709 |
+
def test_session_report_and_totals(self):
|
| 710 |
+
su = SessionUsage()
|
| 711 |
+
su.add(TurnUsage(model="m", thinking_chars=40, content_chars=20,
|
| 712 |
+
elapsed_s=1.0, n_sources=1))
|
| 713 |
+
su.add(TurnUsage(model="m", ok=False, error="x", elapsed_s=0.5))
|
| 714 |
+
tt = su.totals()
|
| 715 |
+
assert tt["turns"] == 2 and tt["ok"] == 1 and tt["failed"] == 1
|
| 716 |
+
assert tt["tokens"] == 15
|
| 717 |
+
rep = su.format_report()
|
| 718 |
+
assert "📊 Session usage" in rep
|
| 719 |
+
assert "2 turns (1 ok, 1 failed)" in rep
|
| 720 |
+
su.clear()
|
| 721 |
+
assert su.totals()["turns"] == 0
|
| 722 |
+
|
| 723 |
+
def test_stream_event_defaults(self):
|
| 724 |
+
e = StreamEvent(kind="content")
|
| 725 |
+
assert e.text == ""
|
| 726 |
+
e2 = StreamEvent(kind="thinking", text="t")
|
| 727 |
+
assert e2.text == "t"
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
# ═══════════════════════════════════════════════════════════
|
| 731 |
+
# INTERACTIVE (command parsing)
|
| 732 |
+
# ═══════════════════════════════════════════════════════════
|
| 733 |
+
class TestInteractive:
|
| 734 |
+
|
| 735 |
+
def test_parse(self):
|
| 736 |
+
from upstage_interactive import parse_command
|
| 737 |
+
assert parse_command("/model pro2") == ("model", "pro2")
|
| 738 |
+
assert parse_command(" /SEARCH off ") == ("search", "off")
|
| 739 |
+
assert parse_command("/quit") == ("quit", "")
|
| 740 |
+
assert parse_command("/") == ("help", "")
|
| 741 |
+
assert parse_command("hello") == ("chat", "hello")
|
| 742 |
+
assert parse_command("") == ("help", "")
|
| 743 |
+
assert parse_command(" ") == ("help", "")
|
| 744 |
+
|
| 745 |
+
def test_session_toggles(self):
|
| 746 |
+
import argparse
|
| 747 |
+
from upstage_interactive import Session
|
| 748 |
+
s = Session(argparse.Namespace(model="pro2", search=True, thinking="med"))
|
| 749 |
+
assert s.up.model == "solar-pro2"
|
| 750 |
+
assert s.up.search is True
|
| 751 |
+
assert s.thinking == "medium"
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
# ═══════════════════════════════════════════════════════════
|
| 755 |
+
# LIVE TESTS (UPSTAGE_LIVE=1)
|
| 756 |
+
# ═══════════════════════════════════════════════════════════
|
| 757 |
+
class TestLive:
|
| 758 |
+
|
| 759 |
+
@live
|
| 760 |
+
def test_connect_and_short_chat(self):
|
| 761 |
+
up = UpstageProvider()
|
| 762 |
+
|
| 763 |
+
async def go():
|
| 764 |
+
await up.connect()
|
| 765 |
+
toks = [t async for t in up.chat(data="What is 2+2? One word.",
|
| 766 |
+
max_tokens=60)]
|
| 767 |
+
return toks
|
| 768 |
+
toks = run(asyncio.wait_for(go(), 120))
|
| 769 |
+
assert up.last_response.strip()
|
| 770 |
+
assert _OPEN not in up.last_response and _CLOSE not in up.last_response
|
| 771 |
+
u = up.last_usage
|
| 772 |
+
assert u.ok is True and u.elapsed_s > 0 and u.tokens > 0
|
| 773 |
+
assert [m["role"] for m in up.history] == ["user", "assistant"]
|
| 774 |
+
|
| 775 |
+
@live
|
| 776 |
+
def test_stream_typed_and_realtime(self):
|
| 777 |
+
up = UpstageProvider()
|
| 778 |
+
|
| 779 |
+
async def go():
|
| 780 |
+
loop = asyncio.get_event_loop()
|
| 781 |
+
token_times = []
|
| 782 |
+
done_time = None
|
| 783 |
+
async for ev in up.stream(data="Count 1 to 3, nothing else.",
|
| 784 |
+
max_tokens=80):
|
| 785 |
+
if ev.kind in ("thinking", "content") and ev.text:
|
| 786 |
+
token_times.append(loop.time())
|
| 787 |
+
elif ev.kind == "done":
|
| 788 |
+
done_time = loop.time()
|
| 789 |
+
return token_times, done_time
|
| 790 |
+
token_times, done_time = run(asyncio.wait_for(go(), 120))
|
| 791 |
+
assert len(token_times) >= 1
|
| 792 |
+
assert done_time is not None
|
| 793 |
+
# realtime: every token event arrived BEFORE the stream ended
|
| 794 |
+
assert all(t < done_time for t in token_times)
|
| 795 |
+
# and arrivals are non-decreasing in time (no buffering reorder)
|
| 796 |
+
assert token_times == sorted(token_times)
|
| 797 |
+
|
| 798 |
+
@live
|
| 799 |
+
def test_search_sources(self):
|
| 800 |
+
up = UpstageProvider()
|
| 801 |
+
|
| 802 |
+
async def go():
|
| 803 |
+
events = [ev async for ev in up.stream(
|
| 804 |
+
data="What is the capital of France? In one word.",
|
| 805 |
+
search=True, max_tokens=120)]
|
| 806 |
+
return events
|
| 807 |
+
events = run(asyncio.wait_for(go(), 180))
|
| 808 |
+
kinds = [e.kind for e in events]
|
| 809 |
+
assert "sources" in kinds
|
| 810 |
+
assert kinds.index("sources") < kinds.index("done")
|
| 811 |
+
assert up.last_sources
|
| 812 |
+
assert all(s["url"].startswith("http") for s in up.last_sources[:3])
|
| 813 |
+
assert "📚 Sources" in up.last_sources_text
|
| 814 |
+
assert up.last_usage.n_sources >= 1
|
| 815 |
+
|
| 816 |
+
@live
|
| 817 |
+
def test_multi_turn_history(self):
|
| 818 |
+
up = UpstageProvider()
|
| 819 |
+
|
| 820 |
+
async def go():
|
| 821 |
+
async for _ in up.chat(data="My name is Ada. Remember it.",
|
| 822 |
+
max_tokens=80):
|
| 823 |
+
pass
|
| 824 |
+
async for _ in up.chat(data="What is my name? One word.",
|
| 825 |
+
max_tokens=80):
|
| 826 |
+
pass
|
| 827 |
+
return up
|
| 828 |
+
up = run(asyncio.wait_for(go(), 180))
|
| 829 |
+
roles = [m["role"] for m in up.history]
|
| 830 |
+
assert roles == ["user", "assistant", "user", "assistant"]
|
| 831 |
+
assert "ada" in up.last_response.lower()
|
| 832 |
+
assert up.session_usage.totals()["turns"] == 2
|
| 833 |
+
|
| 834 |
+
@live
|
| 835 |
+
def test_fresh_credential_capture(self, tmp_path):
|
| 836 |
+
"""Full capture pipeline in a clean cache dir (no pre-existing creds)."""
|
| 837 |
+
old = os.environ.get("UPSTAGE_CACHE_DIR")
|
| 838 |
+
os.environ["UPSTAGE_CACHE_DIR"] = str(tmp_path)
|
| 839 |
+
try:
|
| 840 |
+
up = UpstageProvider()
|
| 841 |
+
|
| 842 |
+
async def go():
|
| 843 |
+
await up.connect() # cold → capture path
|
| 844 |
+
return await up._creds.verify()
|
| 845 |
+
token = run(asyncio.wait_for(go(), 120))
|
| 846 |
+
assert token
|
| 847 |
+
assert up._creds.action_token
|
| 848 |
+
assert (tmp_path / "upstage_creds.json").exists()
|
| 849 |
+
finally:
|
| 850 |
+
if old is None:
|
| 851 |
+
os.environ.pop("UPSTAGE_CACHE_DIR", None)
|
| 852 |
+
else:
|
| 853 |
+
os.environ["UPSTAGE_CACHE_DIR"] = old
|
| 854 |
+
|
| 855 |
+
@live
|
| 856 |
+
def test_reasoning_no_leak(self):
|
| 857 |
+
up = UpstageProvider()
|
| 858 |
+
|
| 859 |
+
async def go():
|
| 860 |
+
async for _ in up.chat(data="Is 97 prime? One word answer.",
|
| 861 |
+
reasoning="high", max_tokens=300):
|
| 862 |
+
pass
|
| 863 |
+
run(asyncio.wait_for(go(), 180))
|
| 864 |
+
assert _OPEN not in up.last_response and _CLOSE not in up.last_response
|
| 865 |
+
assert _OPEN not in up.last_reasoning and _CLOSE not in up.last_reasoning
|
tool_protocol.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-level OpenAI function-call adapter for the Upstage demo transport.
|
| 2 |
+
|
| 3 |
+
Upstage's public Chat API supports native tools, but the anonymous Console demo
|
| 4 |
+
endpoint used by ``upstage_provider.py`` currently ignores ``tools`` and
|
| 5 |
+
``tool_choice``. Solar itself understands a stable tool-call wire format. This
|
| 6 |
+
module exposes that format carefully, parses it, validates arguments against the
|
| 7 |
+
client's JSON Schema, and converts it back to standard OpenAI ``tool_calls``.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
import uuid
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from jsonschema import ValidationError
|
| 18 |
+
from jsonschema.validators import validator_for
|
| 19 |
+
|
| 20 |
+
_NATIVE_RE = re.compile(
|
| 21 |
+
r"<\|tool_call:begin\|>(.*?)"
|
| 22 |
+
r"<\|tool_call:name\|>(.*?)"
|
| 23 |
+
r"<\|tool_call:args\|>(.*?)"
|
| 24 |
+
r"<\|tool_call:end\|>",
|
| 25 |
+
re.DOTALL,
|
| 26 |
+
)
|
| 27 |
+
_XML_RE = re.compile(r"<tool_calls>\s*(.*?)\s*</tool_calls>", re.DOTALL)
|
| 28 |
+
_SINGLE_XML_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL)
|
| 29 |
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
| 30 |
+
_NATIVE_MARKERS = ("<|tool_call:", "<tool_calls>", "<tool_call>")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ToolProtocolError(ValueError):
|
| 34 |
+
"""A request or generated tool call violates the gateway protocol."""
|
| 35 |
+
|
| 36 |
+
def __init__(self, message: str, *, param: str = "tools", code: str = "invalid_tools"):
|
| 37 |
+
super().__init__(message)
|
| 38 |
+
self.param = param
|
| 39 |
+
self.code = code
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class ToolSpec:
|
| 44 |
+
name: str
|
| 45 |
+
description: str
|
| 46 |
+
parameters: dict[str, Any]
|
| 47 |
+
|
| 48 |
+
def prompt_dict(self) -> dict[str, Any]:
|
| 49 |
+
return {
|
| 50 |
+
"name": self.name,
|
| 51 |
+
"description": self.description,
|
| 52 |
+
"parameters": self.parameters,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass(frozen=True)
|
| 57 |
+
class ParsedToolCall:
|
| 58 |
+
name: str
|
| 59 |
+
arguments: dict[str, Any]
|
| 60 |
+
id: str
|
| 61 |
+
|
| 62 |
+
@classmethod
|
| 63 |
+
def create(cls, name: str, arguments: dict[str, Any]) -> "ParsedToolCall":
|
| 64 |
+
return cls(name=name, arguments=arguments, id=f"call_{uuid.uuid4().hex[:24]}")
|
| 65 |
+
|
| 66 |
+
def as_openai(self) -> dict[str, Any]:
|
| 67 |
+
return {
|
| 68 |
+
"id": self.id,
|
| 69 |
+
"type": "function",
|
| 70 |
+
"function": {
|
| 71 |
+
"name": self.name,
|
| 72 |
+
"arguments": json.dumps(
|
| 73 |
+
self.arguments, ensure_ascii=False, separators=(",", ":")
|
| 74 |
+
),
|
| 75 |
+
},
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class ToolConfiguration:
|
| 81 |
+
tools: tuple[ToolSpec, ...]
|
| 82 |
+
choice: str # none | auto | required | specific
|
| 83 |
+
required_name: str | None
|
| 84 |
+
parallel: bool
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def enabled(self) -> bool:
|
| 88 |
+
return bool(self.tools) and self.choice != "none"
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def must_call(self) -> bool:
|
| 92 |
+
return self.choice in {"required", "specific"}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def parse_tool_configuration(payload: dict[str, Any]) -> ToolConfiguration:
|
| 96 |
+
"""Validate OpenAI ``tools``/``tool_choice`` (and legacy functions)."""
|
| 97 |
+
raw_tools = payload.get("tools")
|
| 98 |
+
if raw_tools is None and payload.get("functions") is not None:
|
| 99 |
+
raw_functions = payload.get("functions")
|
| 100 |
+
if not isinstance(raw_functions, list):
|
| 101 |
+
raise ToolProtocolError("'functions' must be an array.", param="functions")
|
| 102 |
+
raw_tools = [{"type": "function", "function": item} for item in raw_functions]
|
| 103 |
+
if raw_tools is None:
|
| 104 |
+
raw_tools = []
|
| 105 |
+
if not isinstance(raw_tools, list):
|
| 106 |
+
raise ToolProtocolError("'tools' must be an array.")
|
| 107 |
+
if len(raw_tools) > 128:
|
| 108 |
+
raise ToolProtocolError("At most 128 function tools are supported.")
|
| 109 |
+
|
| 110 |
+
specs: list[ToolSpec] = []
|
| 111 |
+
names: set[str] = set()
|
| 112 |
+
for index, raw in enumerate(raw_tools):
|
| 113 |
+
param = f"tools[{index}]"
|
| 114 |
+
if not isinstance(raw, dict) or raw.get("type", "function") != "function":
|
| 115 |
+
raise ToolProtocolError(
|
| 116 |
+
"Only tools with type 'function' are supported.", param=param
|
| 117 |
+
)
|
| 118 |
+
fn = raw.get("function")
|
| 119 |
+
if not isinstance(fn, dict):
|
| 120 |
+
raise ToolProtocolError("A function object is required.", param=f"{param}.function")
|
| 121 |
+
name = fn.get("name")
|
| 122 |
+
if not isinstance(name, str) or not _NAME_RE.fullmatch(name):
|
| 123 |
+
raise ToolProtocolError(
|
| 124 |
+
"Function names must be 1-128 letters, digits, underscores, or hyphens.",
|
| 125 |
+
param=f"{param}.function.name",
|
| 126 |
+
)
|
| 127 |
+
if name in names:
|
| 128 |
+
raise ToolProtocolError(f"Duplicate function name '{name}'.", param=param)
|
| 129 |
+
names.add(name)
|
| 130 |
+
description = fn.get("description", "")
|
| 131 |
+
if description is None:
|
| 132 |
+
description = ""
|
| 133 |
+
if not isinstance(description, str):
|
| 134 |
+
raise ToolProtocolError(
|
| 135 |
+
"Function description must be a string.",
|
| 136 |
+
param=f"{param}.function.description",
|
| 137 |
+
)
|
| 138 |
+
parameters = fn.get("parameters", {"type": "object"})
|
| 139 |
+
if not isinstance(parameters, dict):
|
| 140 |
+
raise ToolProtocolError(
|
| 141 |
+
"Function parameters must be a JSON Schema object.",
|
| 142 |
+
param=f"{param}.function.parameters",
|
| 143 |
+
)
|
| 144 |
+
specs.append(ToolSpec(name, description[:4000], parameters))
|
| 145 |
+
|
| 146 |
+
raw_choice = payload.get("tool_choice", payload.get("function_call"))
|
| 147 |
+
required_name: str | None = None
|
| 148 |
+
if raw_choice is None:
|
| 149 |
+
choice = "auto" if specs else "none"
|
| 150 |
+
elif isinstance(raw_choice, str):
|
| 151 |
+
aliases = {"any": "required"}
|
| 152 |
+
choice = aliases.get(raw_choice, raw_choice)
|
| 153 |
+
if choice not in {"none", "auto", "required"}:
|
| 154 |
+
# Legacy function_call may be a function name string.
|
| 155 |
+
if choice in names:
|
| 156 |
+
required_name = choice
|
| 157 |
+
choice = "specific"
|
| 158 |
+
else:
|
| 159 |
+
raise ToolProtocolError(
|
| 160 |
+
"'tool_choice' must be 'none', 'auto', 'required', or a function object.",
|
| 161 |
+
param="tool_choice",
|
| 162 |
+
)
|
| 163 |
+
elif isinstance(raw_choice, dict):
|
| 164 |
+
fn = raw_choice.get("function", raw_choice)
|
| 165 |
+
required_name = fn.get("name") if isinstance(fn, dict) else None
|
| 166 |
+
if not isinstance(required_name, str) or required_name not in names:
|
| 167 |
+
raise ToolProtocolError(
|
| 168 |
+
"The forced function in 'tool_choice' is not present in 'tools'.",
|
| 169 |
+
param="tool_choice",
|
| 170 |
+
)
|
| 171 |
+
choice = "specific"
|
| 172 |
+
else:
|
| 173 |
+
raise ToolProtocolError("Invalid 'tool_choice'.", param="tool_choice")
|
| 174 |
+
|
| 175 |
+
if choice in {"auto", "required", "specific"} and not specs:
|
| 176 |
+
raise ToolProtocolError(
|
| 177 |
+
"'tool_choice' requires at least one function tool.", param="tool_choice"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
parallel = payload.get("parallel_tool_calls", True)
|
| 181 |
+
if not isinstance(parallel, bool):
|
| 182 |
+
raise ToolProtocolError(
|
| 183 |
+
"'parallel_tool_calls' must be a boolean.", param="parallel_tool_calls"
|
| 184 |
+
)
|
| 185 |
+
return ToolConfiguration(tuple(specs), choice, required_name, parallel)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def build_tool_instruction(config: ToolConfiguration) -> str:
|
| 189 |
+
"""Create a bounded, explicit protocol instruction understood by Solar."""
|
| 190 |
+
if not config.enabled:
|
| 191 |
+
return ""
|
| 192 |
+
if config.choice == "specific":
|
| 193 |
+
choice_text = (
|
| 194 |
+
f"You MUST request the function {config.required_name!r}. "
|
| 195 |
+
"Do not choose another function and do not answer in prose."
|
| 196 |
+
)
|
| 197 |
+
elif config.choice == "required":
|
| 198 |
+
choice_text = (
|
| 199 |
+
"You MUST request at least one listed function. Do not answer in prose."
|
| 200 |
+
)
|
| 201 |
+
else:
|
| 202 |
+
choice_text = (
|
| 203 |
+
"Request a function when it is useful or necessary. If no function is "
|
| 204 |
+
"needed, answer the user normally in prose."
|
| 205 |
+
)
|
| 206 |
+
parallel_text = (
|
| 207 |
+
"You may emit several consecutive calls when they are independent."
|
| 208 |
+
if config.parallel
|
| 209 |
+
else "Emit at most one function call."
|
| 210 |
+
)
|
| 211 |
+
catalog = json.dumps(
|
| 212 |
+
[tool.prompt_dict() for tool in config.tools],
|
| 213 |
+
ensure_ascii=False,
|
| 214 |
+
separators=(",", ":"),
|
| 215 |
+
)
|
| 216 |
+
return f"""
|
| 217 |
+
TOOL GATEWAY PROTOCOL (highest priority for response formatting)
|
| 218 |
+
You can request the functions listed below. Never claim that you cannot use tools.
|
| 219 |
+
{choice_text}
|
| 220 |
+
{parallel_text}
|
| 221 |
+
|
| 222 |
+
For every function request, emit exactly this format (without Markdown fences):
|
| 223 |
+
<|tool_call:begin|>call_id<|tool_call:name|>FUNCTION_NAME<|tool_call:args|>{{"argument":"value"}}<|tool_call:end|>
|
| 224 |
+
The args section MUST be one valid JSON object conforming to that function's schema.
|
| 225 |
+
For multiple calls, concatenate complete call blocks. A response containing calls must
|
| 226 |
+
contain only call blocks, with no prose before or after. Never narrate that you will,
|
| 227 |
+
should, or need to call a tool: emit the call block immediately instead. For coding tasks,
|
| 228 |
+
reading, writing, editing, searching, or running workspace content requires the matching
|
| 229 |
+
function when one is listed. Tool results from earlier calls are labelled TOOL RESULT in
|
| 230 |
+
the conversation; use them to continue the task.
|
| 231 |
+
|
| 232 |
+
AVAILABLE FUNCTIONS:
|
| 233 |
+
{catalog}
|
| 234 |
+
END TOOL GATEWAY PROTOCOL
|
| 235 |
+
""".strip()
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _content_to_text(content: Any, *, param: str) -> str:
|
| 239 |
+
if content is None:
|
| 240 |
+
return ""
|
| 241 |
+
if isinstance(content, str):
|
| 242 |
+
return content
|
| 243 |
+
if not isinstance(content, list):
|
| 244 |
+
raise ToolProtocolError(
|
| 245 |
+
"Message content must be a string or an array of text parts.",
|
| 246 |
+
param=param,
|
| 247 |
+
code="invalid_messages",
|
| 248 |
+
)
|
| 249 |
+
parts: list[str] = []
|
| 250 |
+
for index, part in enumerate(content):
|
| 251 |
+
if isinstance(part, str):
|
| 252 |
+
parts.append(part)
|
| 253 |
+
continue
|
| 254 |
+
if not isinstance(part, dict):
|
| 255 |
+
raise ToolProtocolError(
|
| 256 |
+
"Message content parts must be objects.",
|
| 257 |
+
param=f"{param}[{index}]",
|
| 258 |
+
code="invalid_messages",
|
| 259 |
+
)
|
| 260 |
+
kind = part.get("type")
|
| 261 |
+
if kind in {"text", "input_text", "output_text"}:
|
| 262 |
+
text = part.get("text", "")
|
| 263 |
+
if not isinstance(text, str):
|
| 264 |
+
raise ToolProtocolError(
|
| 265 |
+
"Text content must be a string.",
|
| 266 |
+
param=f"{param}[{index}].text",
|
| 267 |
+
code="invalid_messages",
|
| 268 |
+
)
|
| 269 |
+
parts.append(text)
|
| 270 |
+
else:
|
| 271 |
+
raise ToolProtocolError(
|
| 272 |
+
f"Content part type {kind!r} is not supported by this text-only transport.",
|
| 273 |
+
param=f"{param}[{index}].type",
|
| 274 |
+
code="unsupported_content_type",
|
| 275 |
+
)
|
| 276 |
+
return "\n".join(part for part in parts if part)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _existing_tool_call_wire(tool_calls: Any) -> tuple[str, dict[str, str]]:
|
| 280 |
+
if tool_calls is None:
|
| 281 |
+
return "", {}
|
| 282 |
+
if not isinstance(tool_calls, list):
|
| 283 |
+
raise ToolProtocolError(
|
| 284 |
+
"Assistant 'tool_calls' must be an array.",
|
| 285 |
+
param="messages.tool_calls",
|
| 286 |
+
code="invalid_messages",
|
| 287 |
+
)
|
| 288 |
+
blocks: list[str] = []
|
| 289 |
+
call_names: dict[str, str] = {}
|
| 290 |
+
for item in tool_calls:
|
| 291 |
+
if not isinstance(item, dict) or item.get("type", "function") != "function":
|
| 292 |
+
raise ToolProtocolError(
|
| 293 |
+
"Only assistant function tool calls are supported.",
|
| 294 |
+
param="messages.tool_calls",
|
| 295 |
+
code="invalid_messages",
|
| 296 |
+
)
|
| 297 |
+
fn = item.get("function")
|
| 298 |
+
name = fn.get("name") if isinstance(fn, dict) else None
|
| 299 |
+
arguments = fn.get("arguments", "{}") if isinstance(fn, dict) else "{}"
|
| 300 |
+
if not isinstance(name, str) or not _NAME_RE.fullmatch(name):
|
| 301 |
+
raise ToolProtocolError(
|
| 302 |
+
"Assistant tool call has an invalid function name.",
|
| 303 |
+
param="messages.tool_calls.function.name",
|
| 304 |
+
code="invalid_messages",
|
| 305 |
+
)
|
| 306 |
+
if isinstance(arguments, dict):
|
| 307 |
+
arguments = json.dumps(arguments, ensure_ascii=False, separators=(",", ":"))
|
| 308 |
+
if not isinstance(arguments, str):
|
| 309 |
+
raise ToolProtocolError(
|
| 310 |
+
"Assistant tool arguments must be a JSON string or object.",
|
| 311 |
+
param="messages.tool_calls.function.arguments",
|
| 312 |
+
code="invalid_messages",
|
| 313 |
+
)
|
| 314 |
+
call_id = item.get("id")
|
| 315 |
+
if not isinstance(call_id, str) or not call_id:
|
| 316 |
+
call_id = f"call_{uuid.uuid4().hex[:24]}"
|
| 317 |
+
call_names[call_id] = name
|
| 318 |
+
blocks.append(
|
| 319 |
+
f"<|tool_call:begin|>{call_id}<|tool_call:name|>{name}"
|
| 320 |
+
f"<|tool_call:args|>{arguments}<|tool_call:end|>"
|
| 321 |
+
)
|
| 322 |
+
return "".join(blocks), call_names
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def normalize_messages(
|
| 326 |
+
raw_messages: Any, tool_instruction: str = ""
|
| 327 |
+
) -> list[dict[str, str]]:
|
| 328 |
+
"""Convert OpenAI system/developer/tool turns to demo-supported text turns."""
|
| 329 |
+
if not isinstance(raw_messages, list) or not raw_messages:
|
| 330 |
+
raise ToolProtocolError(
|
| 331 |
+
"'messages' must be a non-empty array.",
|
| 332 |
+
param="messages",
|
| 333 |
+
code="invalid_messages",
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
system_parts: list[str] = []
|
| 337 |
+
turns: list[dict[str, str]] = []
|
| 338 |
+
known_call_names: dict[str, str] = {}
|
| 339 |
+
|
| 340 |
+
for index, message in enumerate(raw_messages):
|
| 341 |
+
param = f"messages[{index}]"
|
| 342 |
+
if not isinstance(message, dict):
|
| 343 |
+
raise ToolProtocolError(
|
| 344 |
+
"Every message must be an object.", param=param, code="invalid_messages"
|
| 345 |
+
)
|
| 346 |
+
role = message.get("role")
|
| 347 |
+
if role not in {"system", "developer", "user", "assistant", "tool", "function"}:
|
| 348 |
+
raise ToolProtocolError(
|
| 349 |
+
f"Unsupported message role {role!r}.",
|
| 350 |
+
param=f"{param}.role",
|
| 351 |
+
code="invalid_messages",
|
| 352 |
+
)
|
| 353 |
+
content = _content_to_text(message.get("content"), param=f"{param}.content")
|
| 354 |
+
|
| 355 |
+
if role in {"system", "developer"}:
|
| 356 |
+
if content:
|
| 357 |
+
label = "DEVELOPER" if role == "developer" else "SYSTEM"
|
| 358 |
+
system_parts.append(f"[{label}]\n{content}")
|
| 359 |
+
elif role == "assistant":
|
| 360 |
+
wire, names = _existing_tool_call_wire(message.get("tool_calls"))
|
| 361 |
+
known_call_names.update(names)
|
| 362 |
+
combined = content
|
| 363 |
+
if wire:
|
| 364 |
+
combined = f"{combined}\n{wire}" if combined else wire
|
| 365 |
+
turns.append({"role": "assistant", "content": combined})
|
| 366 |
+
elif role in {"tool", "function"}:
|
| 367 |
+
call_id = message.get("tool_call_id", "")
|
| 368 |
+
name = message.get("name") or known_call_names.get(str(call_id), "unknown")
|
| 369 |
+
turns.append(
|
| 370 |
+
{
|
| 371 |
+
"role": "user",
|
| 372 |
+
"content": (
|
| 373 |
+
f"TOOL RESULT\ncall_id: {call_id}\nfunction: {name}\n"
|
| 374 |
+
f"result:\n{content}\nEND TOOL RESULT"
|
| 375 |
+
),
|
| 376 |
+
}
|
| 377 |
+
)
|
| 378 |
+
else:
|
| 379 |
+
turns.append({"role": "user", "content": content})
|
| 380 |
+
|
| 381 |
+
if tool_instruction:
|
| 382 |
+
system_parts.append(tool_instruction)
|
| 383 |
+
if system_parts:
|
| 384 |
+
turns.insert(0, {"role": "system", "content": "\n\n".join(system_parts)})
|
| 385 |
+
if not any(turn["role"] == "user" for turn in turns):
|
| 386 |
+
raise ToolProtocolError(
|
| 387 |
+
"At least one user or tool-result message is required.",
|
| 388 |
+
param="messages",
|
| 389 |
+
code="invalid_messages",
|
| 390 |
+
)
|
| 391 |
+
return turns
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _coerce_call(item: Any) -> tuple[str, dict[str, Any]]:
|
| 395 |
+
if not isinstance(item, dict):
|
| 396 |
+
raise ToolProtocolError(
|
| 397 |
+
"Generated tool call must be a JSON object.", code="invalid_tool_output"
|
| 398 |
+
)
|
| 399 |
+
fn = item.get("function") if isinstance(item.get("function"), dict) else item
|
| 400 |
+
name = fn.get("name")
|
| 401 |
+
arguments = fn.get("arguments", fn.get("args"))
|
| 402 |
+
if isinstance(arguments, str):
|
| 403 |
+
try:
|
| 404 |
+
arguments = json.loads(arguments)
|
| 405 |
+
except json.JSONDecodeError as exc:
|
| 406 |
+
raise ToolProtocolError(
|
| 407 |
+
f"Generated arguments for {name!r} are not valid JSON: {exc.msg}.",
|
| 408 |
+
code="invalid_tool_output",
|
| 409 |
+
) from exc
|
| 410 |
+
if not isinstance(name, str) or not isinstance(arguments, dict):
|
| 411 |
+
raise ToolProtocolError(
|
| 412 |
+
"Generated tool call needs a function name and a JSON-object arguments value.",
|
| 413 |
+
code="invalid_tool_output",
|
| 414 |
+
)
|
| 415 |
+
return name.strip(), arguments
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _append_missing_json_closers(text: str) -> str | None:
|
| 419 |
+
"""Append only missing ``}``/``]`` delimiters to truncated JSON."""
|
| 420 |
+
stack: list[str] = []
|
| 421 |
+
in_string = False
|
| 422 |
+
escaped = False
|
| 423 |
+
pairs = {"}": "{", "]": "["}
|
| 424 |
+
for character in text:
|
| 425 |
+
if in_string:
|
| 426 |
+
if escaped:
|
| 427 |
+
escaped = False
|
| 428 |
+
elif character == "\\":
|
| 429 |
+
escaped = True
|
| 430 |
+
elif character == '"':
|
| 431 |
+
in_string = False
|
| 432 |
+
continue
|
| 433 |
+
if character == '"':
|
| 434 |
+
in_string = True
|
| 435 |
+
elif character in "{[":
|
| 436 |
+
stack.append(character)
|
| 437 |
+
elif character in "}]":
|
| 438 |
+
if not stack or stack[-1] != pairs[character]:
|
| 439 |
+
return None
|
| 440 |
+
stack.pop()
|
| 441 |
+
if in_string or escaped:
|
| 442 |
+
return None
|
| 443 |
+
closers = "".join("}" if opener == "{" else "]" for opener in reversed(stack))
|
| 444 |
+
return text + closers
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _repair_tool_json(text: str) -> Any | None:
|
| 448 |
+
"""Apply bounded repairs seen in Solar's narrated JSON fallback."""
|
| 449 |
+
variants = [text]
|
| 450 |
+
# Solar occasionally double-escapes an arguments object as though it were
|
| 451 |
+
# itself a JSON string. Try removing exactly one escape layer.
|
| 452 |
+
collapsed = text.replace("\\\\", "\\")
|
| 453 |
+
if collapsed != text:
|
| 454 |
+
variants.append(collapsed)
|
| 455 |
+
for variant in variants:
|
| 456 |
+
closed = _append_missing_json_closers(variant)
|
| 457 |
+
if closed is None:
|
| 458 |
+
continue
|
| 459 |
+
try:
|
| 460 |
+
return json.loads(closed)
|
| 461 |
+
except json.JSONDecodeError:
|
| 462 |
+
continue
|
| 463 |
+
return None
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _json_candidates(text: str) -> list[Any]:
|
| 467 |
+
candidates: list[Any] = []
|
| 468 |
+
xml_matches = list(_XML_RE.finditer(text))
|
| 469 |
+
single_matches = list(_SINGLE_XML_RE.finditer(text))
|
| 470 |
+
for match in xml_matches:
|
| 471 |
+
try:
|
| 472 |
+
candidates.append(json.loads(match.group(1)))
|
| 473 |
+
except json.JSONDecodeError as exc:
|
| 474 |
+
raise ToolProtocolError(
|
| 475 |
+
f"Generated <tool_calls> payload is invalid JSON: {exc.msg}.",
|
| 476 |
+
code="invalid_tool_output",
|
| 477 |
+
) from exc
|
| 478 |
+
for match in single_matches:
|
| 479 |
+
try:
|
| 480 |
+
candidates.append(json.loads(match.group(1)))
|
| 481 |
+
except json.JSONDecodeError as exc:
|
| 482 |
+
raise ToolProtocolError(
|
| 483 |
+
f"Generated <tool_call> payload is invalid JSON: {exc.msg}.",
|
| 484 |
+
code="invalid_tool_output",
|
| 485 |
+
) from exc
|
| 486 |
+
if candidates:
|
| 487 |
+
residue = _XML_RE.sub("", text)
|
| 488 |
+
residue = _SINGLE_XML_RE.sub("", residue).strip()
|
| 489 |
+
if residue:
|
| 490 |
+
raise ToolProtocolError(
|
| 491 |
+
"Generated tool calls contained prose outside the call blocks.",
|
| 492 |
+
code="invalid_tool_output",
|
| 493 |
+
)
|
| 494 |
+
return candidates
|
| 495 |
+
|
| 496 |
+
stripped = text.strip()
|
| 497 |
+
if stripped.startswith(("{", "[")):
|
| 498 |
+
try:
|
| 499 |
+
value = json.loads(stripped)
|
| 500 |
+
except json.JSONDecodeError as exc:
|
| 501 |
+
tool_shaped = '"name"' in stripped and (
|
| 502 |
+
'"arguments"' in stripped or '"args"' in stripped
|
| 503 |
+
)
|
| 504 |
+
value = _repair_tool_json(stripped) if tool_shaped else None
|
| 505 |
+
if value is None:
|
| 506 |
+
if tool_shaped:
|
| 507 |
+
raise ToolProtocolError(
|
| 508 |
+
f"Generated tool-call JSON is malformed: {exc.msg}.",
|
| 509 |
+
code="invalid_tool_output",
|
| 510 |
+
) from exc
|
| 511 |
+
return []
|
| 512 |
+
# Do not misclassify an ordinary JSON answer as a tool call.
|
| 513 |
+
if isinstance(value, dict) and (
|
| 514 |
+
"tool_calls" in value or ("name" in value and ("arguments" in value or "args" in value))
|
| 515 |
+
):
|
| 516 |
+
candidates.append(value.get("tool_calls", value))
|
| 517 |
+
elif isinstance(value, list) and value and all(
|
| 518 |
+
isinstance(x, dict) and "name" in x for x in value
|
| 519 |
+
):
|
| 520 |
+
candidates.append(value)
|
| 521 |
+
return candidates
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def parse_embedded_tool_calls(
|
| 525 |
+
text: str, config: ToolConfiguration
|
| 526 |
+
) -> list[ParsedToolCall]:
|
| 527 |
+
"""Recover the last schema-valid call JSON from narrated model output.
|
| 528 |
+
|
| 529 |
+
This is intentionally separate from the strict parser. The gateway invokes it
|
| 530 |
+
only after a narrow detector confirms that Solar is narrating an intended call.
|
| 531 |
+
It lets a coding turn recover when the model writes the correct call JSON after
|
| 532 |
+
phrases such as "I will call the tool", while ordinary JSON answers stay text.
|
| 533 |
+
"""
|
| 534 |
+
decoder = json.JSONDecoder()
|
| 535 |
+
candidates: list[Any] = []
|
| 536 |
+
for index, character in enumerate(text):
|
| 537 |
+
if character not in "{[":
|
| 538 |
+
continue
|
| 539 |
+
try:
|
| 540 |
+
value, _ = decoder.raw_decode(text[index:])
|
| 541 |
+
except json.JSONDecodeError:
|
| 542 |
+
continue
|
| 543 |
+
if isinstance(value, dict) and (
|
| 544 |
+
"tool_calls" in value
|
| 545 |
+
or ("name" in value and ("arguments" in value or "args" in value))
|
| 546 |
+
):
|
| 547 |
+
candidates.append(value)
|
| 548 |
+
elif isinstance(value, list) and value and all(
|
| 549 |
+
isinstance(item, dict) and "name" in item for item in value
|
| 550 |
+
):
|
| 551 |
+
candidates.append(value)
|
| 552 |
+
|
| 553 |
+
# The final object is normally the model's actual proposed call; earlier
|
| 554 |
+
# objects are often examples copied while it reasons about the protocol.
|
| 555 |
+
for candidate in reversed(candidates):
|
| 556 |
+
try:
|
| 557 |
+
return parse_generated_tool_calls(
|
| 558 |
+
json.dumps(candidate, ensure_ascii=False, separators=(",", ":")),
|
| 559 |
+
config,
|
| 560 |
+
)
|
| 561 |
+
except ToolProtocolError:
|
| 562 |
+
continue
|
| 563 |
+
return []
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def parse_generated_tool_calls(
|
| 567 |
+
text: str, config: ToolConfiguration
|
| 568 |
+
) -> list[ParsedToolCall]:
|
| 569 |
+
"""Parse and schema-check Solar output, returning OpenAI-ready calls."""
|
| 570 |
+
if not config.enabled:
|
| 571 |
+
return []
|
| 572 |
+
|
| 573 |
+
raw_calls: list[tuple[str, dict[str, Any]]] = []
|
| 574 |
+
native_matches = list(_NATIVE_RE.finditer(text))
|
| 575 |
+
for match in native_matches:
|
| 576 |
+
name = match.group(2).strip()
|
| 577 |
+
try:
|
| 578 |
+
arguments = json.loads(match.group(3).strip())
|
| 579 |
+
except json.JSONDecodeError as exc:
|
| 580 |
+
raise ToolProtocolError(
|
| 581 |
+
f"Generated arguments for {name!r} are not valid JSON: {exc.msg}.",
|
| 582 |
+
code="invalid_tool_output",
|
| 583 |
+
) from exc
|
| 584 |
+
if not isinstance(arguments, dict):
|
| 585 |
+
raise ToolProtocolError(
|
| 586 |
+
f"Generated arguments for {name!r} must be a JSON object.",
|
| 587 |
+
code="invalid_tool_output",
|
| 588 |
+
)
|
| 589 |
+
raw_calls.append((name, arguments))
|
| 590 |
+
if native_matches and _NATIVE_RE.sub("", text).strip():
|
| 591 |
+
raise ToolProtocolError(
|
| 592 |
+
"Generated tool calls contained prose outside the call blocks.",
|
| 593 |
+
code="invalid_tool_output",
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
if not raw_calls:
|
| 597 |
+
for value in _json_candidates(text):
|
| 598 |
+
items = value if isinstance(value, list) else [value]
|
| 599 |
+
raw_calls.extend(_coerce_call(item) for item in items)
|
| 600 |
+
|
| 601 |
+
if not raw_calls:
|
| 602 |
+
if any(marker in text for marker in _NATIVE_MARKERS):
|
| 603 |
+
raise ToolProtocolError(
|
| 604 |
+
"The model emitted an incomplete tool-call block.",
|
| 605 |
+
code="invalid_tool_output",
|
| 606 |
+
)
|
| 607 |
+
return []
|
| 608 |
+
|
| 609 |
+
by_name = {tool.name: tool for tool in config.tools}
|
| 610 |
+
parsed: list[ParsedToolCall] = []
|
| 611 |
+
for name, arguments in raw_calls:
|
| 612 |
+
if config.required_name and name != config.required_name:
|
| 613 |
+
raise ToolProtocolError(
|
| 614 |
+
f"The model requested {name!r}; {config.required_name!r} was forced.",
|
| 615 |
+
code="invalid_tool_output",
|
| 616 |
+
)
|
| 617 |
+
spec = by_name.get(name)
|
| 618 |
+
if spec is None:
|
| 619 |
+
raise ToolProtocolError(
|
| 620 |
+
f"The model requested unknown function {name!r}.",
|
| 621 |
+
code="invalid_tool_output",
|
| 622 |
+
)
|
| 623 |
+
try:
|
| 624 |
+
validator_cls = validator_for(spec.parameters)
|
| 625 |
+
validator_cls.check_schema(spec.parameters)
|
| 626 |
+
validator_cls(spec.parameters).validate(arguments)
|
| 627 |
+
except ValidationError as exc:
|
| 628 |
+
path = ".".join(str(part) for part in exc.absolute_path)
|
| 629 |
+
where = f" at {path}" if path else ""
|
| 630 |
+
raise ToolProtocolError(
|
| 631 |
+
f"Arguments for {name!r} fail its JSON Schema{where}: {exc.message}",
|
| 632 |
+
code="invalid_tool_output",
|
| 633 |
+
) from exc
|
| 634 |
+
except Exception:
|
| 635 |
+
# Some clients send permissive vendor JSON-Schema dialects. Do not
|
| 636 |
+
# reject a model call solely because the schema itself is unknown.
|
| 637 |
+
pass
|
| 638 |
+
parsed.append(ParsedToolCall.create(name, arguments))
|
| 639 |
+
|
| 640 |
+
if not config.parallel and len(parsed) > 1:
|
| 641 |
+
raise ToolProtocolError(
|
| 642 |
+
"The model emitted multiple calls while parallel_tool_calls is false.",
|
| 643 |
+
code="invalid_tool_output",
|
| 644 |
+
)
|
| 645 |
+
return parsed
|
upstage_interactive.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
══════════════════════════════════════════════════════════════
|
| 4 |
+
💬 upstage_interactive.py — live chat REPL for Upstage Solar (v3)
|
| 5 |
+
|
| 6 |
+
Run: python3 upstage_interactive.py
|
| 7 |
+
python3 upstage_interactive.py --model pro2 --search
|
| 8 |
+
python3 upstage_interactive.py --thinking high
|
| 9 |
+
python3 upstage_interactive.py --selftest (offline smoke check)
|
| 10 |
+
|
| 11 |
+
Type messages and hit enter — tokens stream in live over a single
|
| 12 |
+
asyncio loop (curl_cffi AsyncSession, no threads).
|
| 13 |
+
|
| 14 |
+
Rendering:
|
| 15 |
+
assistant ▸ 💭 thinking tokens (dim)
|
| 16 |
+
📝 answer tokens (bright)
|
| 17 |
+
⏱ 3.2s · first token 1.1s · ~128 tok (est) · 40 tok/s · solar-pro3
|
| 18 |
+
|
| 19 |
+
Commands:
|
| 20 |
+
/help this list
|
| 21 |
+
/models list models (★ = active)
|
| 22 |
+
/model <name|alias> switch model (solar3, pro2, syn, mini…)
|
| 23 |
+
/system <text> set the system prompt
|
| 24 |
+
/search on|off web search (auto-high reasoning when on)
|
| 25 |
+
/think auto|low|med|high reasoning effort (auto = search-based)
|
| 26 |
+
/history show conversation turns
|
| 27 |
+
/sources show sources from the last search
|
| 28 |
+
/usage session usage report (the usage department)
|
| 29 |
+
/clear drop conversation history
|
| 30 |
+
/reset clear history + last_* state + usage stats
|
| 31 |
+
/credits force credential re-capture (~2-5s)
|
| 32 |
+
/quit exit (also: Ctrl-D)
|
| 33 |
+
══════════════════════════════════════════════════════════════
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import argparse
|
| 39 |
+
import asyncio
|
| 40 |
+
import json
|
| 41 |
+
import os
|
| 42 |
+
import sys
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
|
| 45 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 46 |
+
|
| 47 |
+
from upstage_provider import UpstageProvider # noqa: E402
|
| 48 |
+
|
| 49 |
+
# ─────────────────────────────────────────────────────────────
|
| 50 |
+
# colors (auto-off when piped / NO_COLOR set)
|
| 51 |
+
# ─────────────────────────────────────────────────────────────
|
| 52 |
+
HAS_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
| 53 |
+
|
| 54 |
+
def c(text: str, code: str) -> str:
|
| 55 |
+
return f"\033[{code}m{text}\033[0m" if HAS_COLOR else text
|
| 56 |
+
|
| 57 |
+
DIM, BOLD, CYAN, GREEN, YELLOW, RED, MAGENTA = "2", "1", "36", "32", "33", "31", "35"
|
| 58 |
+
|
| 59 |
+
PROMPT = c("you ▸ ", BOLD + CYAN)
|
| 60 |
+
CONT_PROMPT = c(" ⋮ ", BOLD + CYAN)
|
| 61 |
+
BANNER_RULE = "─" * 62
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ─────────────────────────────────────────────────────────────
|
| 65 |
+
# command parsing (pure → unit-testable)
|
| 66 |
+
# ─────────────────────────────────────────────────────────────
|
| 67 |
+
VALID_MODELS_ALIASES = {
|
| 68 |
+
"solar-pro3", "solar-pro2", "syn-pro", "upstage/solar-1-mini-chat",
|
| 69 |
+
"solar3", "solar2", "syn", "mini", "pro3", "pro2",
|
| 70 |
+
}
|
| 71 |
+
VALID_THINKING = {"auto", "low", "med", "medium", "high"}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def parse_command(line: str) -> tuple[str, str]:
|
| 75 |
+
"""
|
| 76 |
+
' /model pro2 ' → ("model", "pro2")
|
| 77 |
+
'hello world' → ("chat", "hello world")
|
| 78 |
+
'/' → ("help", "")
|
| 79 |
+
"""
|
| 80 |
+
s = line.strip()
|
| 81 |
+
if not s:
|
| 82 |
+
return ("help", "")
|
| 83 |
+
if s.startswith("/"):
|
| 84 |
+
cmd, _, arg = s[1:].partition(" ")
|
| 85 |
+
return (cmd.strip().lower() or "help", arg.strip())
|
| 86 |
+
return ("chat", s)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ─────────────────────────────────────────────────────────────
|
| 90 |
+
# the REPL
|
| 91 |
+
# ─────────────────────────────────────────────────────────────
|
| 92 |
+
class Session:
|
| 93 |
+
def __init__(self, args):
|
| 94 |
+
self.up = UpstageProvider(
|
| 95 |
+
model=args.model or None,
|
| 96 |
+
search=bool(args.search),
|
| 97 |
+
)
|
| 98 |
+
self.thinking: str | None = None
|
| 99 |
+
if args.thinking and args.thinking != "auto":
|
| 100 |
+
self.thinking = "medium" if args.thinking == "med" else args.thinking
|
| 101 |
+
|
| 102 |
+
# ── one chat turn (realtime, typed events) ───────────
|
| 103 |
+
async def chat(self, text: str):
|
| 104 |
+
kwargs: dict = {}
|
| 105 |
+
if self.thinking:
|
| 106 |
+
kwargs["reasoning"] = self.thinking
|
| 107 |
+
|
| 108 |
+
print(c("assistant ▸ ", BOLD + MAGENTA), end="", flush=True)
|
| 109 |
+
phase: str | None = None # None → "thinking" → "content"
|
| 110 |
+
prefix_len = len("assistant ▸ ")
|
| 111 |
+
try:
|
| 112 |
+
async for ev in self.up.stream(data=text, **kwargs):
|
| 113 |
+
if ev.kind == "sources":
|
| 114 |
+
try:
|
| 115 |
+
n = len(json.loads(ev.text)["sources"])
|
| 116 |
+
except Exception:
|
| 117 |
+
n = "?"
|
| 118 |
+
print(c(f" [📚 {n} sources collected]", DIM),
|
| 119 |
+
end="", flush=True)
|
| 120 |
+
elif ev.kind == "thinking":
|
| 121 |
+
if phase is None:
|
| 122 |
+
print(c("💭 ", YELLOW), end="", flush=True)
|
| 123 |
+
phase = "thinking"
|
| 124 |
+
print(c(ev.text, DIM), end="", flush=True)
|
| 125 |
+
elif ev.kind == "content":
|
| 126 |
+
if phase is None:
|
| 127 |
+
pass # starts right after "assistant ▸ "
|
| 128 |
+
elif phase == "thinking":
|
| 129 |
+
print("\n" + " " * prefix_len + c("📝 ", CYAN),
|
| 130 |
+
end="", flush=True)
|
| 131 |
+
phase = "content"
|
| 132 |
+
print(ev.text, end="", flush=True)
|
| 133 |
+
elif ev.kind == "done":
|
| 134 |
+
pass
|
| 135 |
+
print() # newline after the stream
|
| 136 |
+
except KeyboardInterrupt:
|
| 137 |
+
print()
|
| 138 |
+
print(c("… (interrupted)", YELLOW))
|
| 139 |
+
return
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"\n⚠ {e}", end="")
|
| 142 |
+
hint = " ↳ try: /credits" if "auth" in str(e).lower() else ""
|
| 143 |
+
print(c(hint, YELLOW if hint else RED))
|
| 144 |
+
return
|
| 145 |
+
|
| 146 |
+
u = self.up.last_usage
|
| 147 |
+
if u:
|
| 148 |
+
print(c(" " + u.format_line(), DIM))
|
| 149 |
+
if self.up.last_sources:
|
| 150 |
+
for s in self.up.last_sources[:3]:
|
| 151 |
+
print(c(f" [{s['index']}] {s['title']} — {s['url']}", DIM))
|
| 152 |
+
if len(self.up.last_sources) > 3:
|
| 153 |
+
print(c(f" … {len(self.up.last_sources) - 3} more "
|
| 154 |
+
f"(/sources)", DIM))
|
| 155 |
+
|
| 156 |
+
# ── commands ─────────────────────────────────────────
|
| 157 |
+
async def handle(self, line: str) -> bool:
|
| 158 |
+
"""Return False to quit."""
|
| 159 |
+
cmd, arg = parse_command(line)
|
| 160 |
+
|
| 161 |
+
if cmd == "chat":
|
| 162 |
+
await self.chat(arg)
|
| 163 |
+
return True
|
| 164 |
+
|
| 165 |
+
if cmd == "quit":
|
| 166 |
+
print(c("bye 👋", DIM))
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
if cmd == "help":
|
| 170 |
+
print(__doc__.split("Commands:")[-1].strip())
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
if cmd == "models":
|
| 174 |
+
for m in self.up.list_models():
|
| 175 |
+
mark = c("★", YELLOW) if m["active"] else " "
|
| 176 |
+
print(f" {mark} {m['name']:<28} "
|
| 177 |
+
f"reasoning={m['reasoning']} max_tokens={m['max_tokens']}")
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
if cmd == "model":
|
| 181 |
+
if not arg:
|
| 182 |
+
print(f" current: {self.up.model}")
|
| 183 |
+
return True
|
| 184 |
+
self.up.set_model(arg)
|
| 185 |
+
print(f" model → {self.up.model}")
|
| 186 |
+
return True
|
| 187 |
+
|
| 188 |
+
if cmd == "system":
|
| 189 |
+
self.up.set_system(arg)
|
| 190 |
+
print(f" system prompt set ({len(arg)} chars)" if arg
|
| 191 |
+
else " system prompt cleared")
|
| 192 |
+
return True
|
| 193 |
+
|
| 194 |
+
if cmd == "search":
|
| 195 |
+
on = arg.lower() in ("on", "1", "true", "yes") if arg else None
|
| 196 |
+
if on is None:
|
| 197 |
+
print(f" search is {'ON' if self.up.search else 'off'} "
|
| 198 |
+
f"(usage: /search on|off)")
|
| 199 |
+
return True
|
| 200 |
+
self.up.set_search(on)
|
| 201 |
+
print(f" search {'ON 🔍 (auto-high reasoning)' if on else 'off'}")
|
| 202 |
+
return True
|
| 203 |
+
|
| 204 |
+
if cmd == "think":
|
| 205 |
+
if not arg:
|
| 206 |
+
cur = self.thinking or "auto"
|
| 207 |
+
print(f" thinking: {cur} (auto = high w/ search, low without)")
|
| 208 |
+
return True
|
| 209 |
+
a = arg.lower()
|
| 210 |
+
if a not in VALID_THINKING:
|
| 211 |
+
print(c(f" ✗ unknown effort {arg!r} — use auto|low|med|high", RED))
|
| 212 |
+
return True
|
| 213 |
+
self.thinking = ("medium" if a == "med" else a) if a != "auto" else None
|
| 214 |
+
print(f" thinking: {self.thinking or 'auto'}")
|
| 215 |
+
return True
|
| 216 |
+
|
| 217 |
+
if cmd == "history":
|
| 218 |
+
hist = self.up.get_history()
|
| 219 |
+
if not hist:
|
| 220 |
+
print(" (empty)")
|
| 221 |
+
return True
|
| 222 |
+
for m in hist:
|
| 223 |
+
body = m["content"].replace("\n", " ")
|
| 224 |
+
if len(body) > 100:
|
| 225 |
+
body = body[:100] + "…"
|
| 226 |
+
print(f" {c(m['role'] + ':', DIM)} {body}")
|
| 227 |
+
return True
|
| 228 |
+
|
| 229 |
+
if cmd == "sources":
|
| 230 |
+
if not self.up.last_sources:
|
| 231 |
+
print(" (no sources yet — use /search on)")
|
| 232 |
+
return True
|
| 233 |
+
print(self.up.last_sources_text)
|
| 234 |
+
return True
|
| 235 |
+
|
| 236 |
+
if cmd in ("usage", "stats"):
|
| 237 |
+
report = self.up.session_usage.format_report()
|
| 238 |
+
if "no turns yet" in report:
|
| 239 |
+
print(" (no turns yet)")
|
| 240 |
+
return True
|
| 241 |
+
print(report)
|
| 242 |
+
return True
|
| 243 |
+
|
| 244 |
+
if cmd == "clear":
|
| 245 |
+
self.up.clear_history()
|
| 246 |
+
print(" history cleared")
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
if cmd == "reset":
|
| 250 |
+
self.up.new_session()
|
| 251 |
+
print(" session reset (history + sources + traces + usage stats)")
|
| 252 |
+
return True
|
| 253 |
+
|
| 254 |
+
if cmd == "credits":
|
| 255 |
+
print(" re-capturing credentials (pure HTTP, ~2-5s)…", flush=True)
|
| 256 |
+
try:
|
| 257 |
+
await self.up.refresh_credentials()
|
| 258 |
+
tok = await self.up._creds.verify()
|
| 259 |
+
print(c(" ✓ fresh credentials" if tok
|
| 260 |
+
else " ✗ verify failed", GREEN if tok else RED))
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(c(f" ✗ {e}", RED))
|
| 263 |
+
return True
|
| 264 |
+
|
| 265 |
+
print(c(f" ✗ unknown /{cmd} — try /help", YELLOW))
|
| 266 |
+
return True
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ─────────────────────────────────────────────────────────────
|
| 270 |
+
# async REPL driver — one event loop for the whole session
|
| 271 |
+
# ─────────────────────────────────────────────────────────────
|
| 272 |
+
async def run_repl(s: Session) -> None:
|
| 273 |
+
print(c("⏳ connecting (cached creds → instant, else ~2-5s capture)…",
|
| 274 |
+
DIM), flush=True)
|
| 275 |
+
try:
|
| 276 |
+
await s.up.connect()
|
| 277 |
+
print(c("✓ connected", GREEN))
|
| 278 |
+
except Exception as e:
|
| 279 |
+
print(c(f"✗ connect failed: {e}\n (credentials will be retried on "
|
| 280 |
+
f"first message)", RED))
|
| 281 |
+
print()
|
| 282 |
+
|
| 283 |
+
while True:
|
| 284 |
+
try:
|
| 285 |
+
line = input(PROMPT)
|
| 286 |
+
except (EOFError, KeyboardInterrupt):
|
| 287 |
+
print(c("\nbye 👋", DIM))
|
| 288 |
+
return
|
| 289 |
+
|
| 290 |
+
# multi-line: trailing backslash continues
|
| 291 |
+
while line.rstrip().endswith("\\"):
|
| 292 |
+
try:
|
| 293 |
+
line = line.rstrip()[:-1] + "\n" + input(CONT_PROMPT)
|
| 294 |
+
except (EOFError, KeyboardInterrupt):
|
| 295 |
+
print()
|
| 296 |
+
return
|
| 297 |
+
|
| 298 |
+
if not line.strip():
|
| 299 |
+
continue
|
| 300 |
+
try:
|
| 301 |
+
if not await s.handle(line):
|
| 302 |
+
return
|
| 303 |
+
except KeyboardInterrupt:
|
| 304 |
+
print(c("\n… (cancelled)", YELLOW))
|
| 305 |
+
except Exception as e:
|
| 306 |
+
print(c(f"✗ {type(e).__name__}: {e}", RED))
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ─────────────────────────────────────────────────────────────
|
| 310 |
+
# selftest — offline smoke check, no network
|
| 311 |
+
# ─────────────────────────────────────────────────────────────
|
| 312 |
+
def selftest() -> int:
|
| 313 |
+
print("selftest: starting (offline)…")
|
| 314 |
+
assert parse_command("/model pro2") == ("model", "pro2")
|
| 315 |
+
assert parse_command(" /SEARCH off ") == ("search", "off")
|
| 316 |
+
assert parse_command("/quit") == ("quit", "")
|
| 317 |
+
assert parse_command("/") == ("help", "")
|
| 318 |
+
assert parse_command("plain message") == ("chat", "plain message")
|
| 319 |
+
assert parse_command("") == ("help", "")
|
| 320 |
+
|
| 321 |
+
s = Session(argparse.Namespace(model=None, search=False, thinking=None))
|
| 322 |
+
assert s.up.model == "solar-pro3"
|
| 323 |
+
s.up.set_model("mini")
|
| 324 |
+
assert s.up.model == "upstage/solar-1-mini-chat"
|
| 325 |
+
s.up.set_search(True)
|
| 326 |
+
assert s.up.search is True
|
| 327 |
+
assert s.thinking is None
|
| 328 |
+
assert s.up.list_models()
|
| 329 |
+
assert UpstageProvider.model_info()["thinking"] is True
|
| 330 |
+
|
| 331 |
+
# usage department (offline)
|
| 332 |
+
from upstage_provider import TurnUsage, SessionUsage, ThinkSplitter
|
| 333 |
+
tu = TurnUsage(model="solar-pro3", thinking_chars=120, content_chars=40,
|
| 334 |
+
elapsed_s=2.0, first_token_s=0.5, n_sources=2)
|
| 335 |
+
assert tu.tokens == 40 # (120+40)/4 estimate
|
| 336 |
+
assert tu.tokens_estimated is True
|
| 337 |
+
assert "solar-pro3" in tu.format_line()
|
| 338 |
+
assert "📚 2" in tu.format_line()
|
| 339 |
+
tu.api_usage = {"prompt_tokens": 10, "completion_tokens": 30, "total_tokens": 40}
|
| 340 |
+
assert tu.tokens == 40 and tu.tokens_estimated is False
|
| 341 |
+
su = SessionUsage()
|
| 342 |
+
su.add(tu)
|
| 343 |
+
rep = su.format_report()
|
| 344 |
+
assert "TOTAL" in rep and "1 turns" in rep
|
| 345 |
+
tu2 = TurnUsage(model="mini", ok=False, error="boom", elapsed_s=1.0)
|
| 346 |
+
su.add(tu2)
|
| 347 |
+
assert su.totals()["failed"] == 1
|
| 348 |
+
# think splitter (last 6 chars held back as possible partial tag)
|
| 349 |
+
sp = ThinkSplitter()
|
| 350 |
+
assert sp.feed("ab") == []
|
| 351 |
+
assert sp.feed("let me ") == [("content", "abl")]
|
| 352 |
+
assert sp.feed("ok") == [("content", "et")]
|
| 353 |
+
assert sp.flush() == [("content", " me ok")]
|
| 354 |
+
sp2 = ThinkSplitter()
|
| 355 |
+
assert sp2.feed("a<th") == []
|
| 356 |
+
assert sp2.feed("ink>R") == [("content", "a")]
|
| 357 |
+
assert sp2.feed("ans") == []
|
| 358 |
+
assert sp2.flush() == [("thinking", "Rans")]
|
| 359 |
+
print("selftest: ✅ all checks passed")
|
| 360 |
+
return 0
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ─────────────────────────────────────────────────────────────
|
| 364 |
+
def banner(s: Session):
|
| 365 |
+
print(c(BANNER_RULE, DIM))
|
| 366 |
+
print(c(" ☀️ Upstage Solar — interactive (v3, fully async)", BOLD))
|
| 367 |
+
print(c(BANNER_RULE, DIM))
|
| 368 |
+
print(f" {s.up}")
|
| 369 |
+
print(f" {c('type /help for commands · /usage for stats · /quit to exit', DIM)}\n")
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def main() -> int:
|
| 373 |
+
ap = argparse.ArgumentParser(description="Upstage Solar REPL")
|
| 374 |
+
ap.add_argument("--model", default=None,
|
| 375 |
+
help="initial model (solar3, pro2, syn, mini, …)")
|
| 376 |
+
ap.add_argument("--search", action="store_true",
|
| 377 |
+
help="start with web search ON")
|
| 378 |
+
ap.add_argument("--thinking", default="auto",
|
| 379 |
+
choices=["auto", "low", "med", "high"],
|
| 380 |
+
help="reasoning effort (default: auto)")
|
| 381 |
+
ap.add_argument("--selftest", action="store_true",
|
| 382 |
+
help="run offline smoke checks and exit")
|
| 383 |
+
args = ap.parse_args()
|
| 384 |
+
|
| 385 |
+
if args.selftest:
|
| 386 |
+
return selftest()
|
| 387 |
+
|
| 388 |
+
s = Session(args)
|
| 389 |
+
banner(s)
|
| 390 |
+
|
| 391 |
+
try:
|
| 392 |
+
asyncio.run(run_repl(s))
|
| 393 |
+
finally:
|
| 394 |
+
pass
|
| 395 |
+
return 0
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
if __name__ == "__main__":
|
| 399 |
+
try:
|
| 400 |
+
raise SystemExit(main())
|
| 401 |
+
except KeyboardInterrupt:
|
| 402 |
+
print(c("\nbye 👋", DIM))
|
| 403 |
+
raise SystemExit(0)
|
upstage_provider.py
ADDED
|
@@ -0,0 +1,1374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
══════════════════════════════════════════════════════════════════
|
| 3 |
+
☀️ Upstage Solar Provider — v3, fully async, curl_cffi only
|
| 4 |
+
|
| 5 |
+
Async streaming reasoning model via console.upstage.ai
|
| 6 |
+
4 models · Web search · Reasoning modes
|
| 7 |
+
Auto-credentials via PURE HTTP (curl_cffi) · Cached to disk
|
| 8 |
+
NO requests · NO DrissionPage · NO threads · NO fake "hi" prompt
|
| 9 |
+
|
| 10 |
+
┌────────────────────────────────────────────────────────────┐
|
| 11 |
+
│ v3 changes (the "realtime" rebuild): │
|
| 12 |
+
│ │
|
| 13 |
+
│ • 100% asyncio — one event loop, zero thread bridges. │
|
| 14 |
+
│ The v2 design ran the completion stream in a worker │
|
| 15 |
+
│ thread (plain `requests` + queue polling). v3 streams │
|
| 16 |
+
│ with curl_cffi's native AsyncSession: │
|
| 17 |
+
│ │
|
| 18 |
+
│ async for line in r.aiter_lines(): → yield │
|
| 19 |
+
│ │
|
| 20 |
+
│ Every chunk is parsed and yielded the instant it hits │
|
| 21 |
+
│ the socket — true realtime. │
|
| 22 |
+
│ • curl_cffi ONLY — Chrome TLS impersonation on every │
|
| 23 |
+
│ call (credentials AND completions). `requests` is gone. │
|
| 24 |
+
│ • Typed stream: stream() yields StreamEvent(kind, text) │
|
| 25 |
+
│ with kind ∈ {"sources","thinking","content","done"}. │
|
| 26 |
+
│ chat() stays as the plain-string convenience API. │
|
| 27 |
+
│ • Usage department: TurnUsage + SessionUsage. │
|
| 28 |
+
│ Every turn records elapsed time, time-to-first-token, │
|
| 29 |
+
│ thinking/answer char counts, estimated tokens, │
|
| 30 |
+
│ tokens/sec, sources, API-reported usage (when non-zero).│
|
| 31 |
+
│ → up.last_usage, up.session_usage.format_report() │
|
| 32 |
+
└────────────────────────────────────────────────────────────┘
|
| 33 |
+
|
| 34 |
+
┌────────────────────────────────────────────────────────────┐
|
| 35 |
+
│ Credential Pipeline (v2, kept — no browser): │
|
| 36 |
+
│ │
|
| 37 |
+
│ The console is a Next.js app. Its client JS bundles embed │
|
| 38 |
+
│ every server action id, e.g.: │
|
| 39 |
+
│ │
|
| 40 |
+
│ createServerReference)("002f44cb…d5", …, │
|
| 41 |
+
│ "getConsoleCsrfToken") │
|
| 42 |
+
│ │
|
| 43 |
+
│ 1. Load cache/upstage_creds.json │
|
| 44 |
+
│ 2. Verify CSRF token via RSC POST │
|
| 45 |
+
│ 3. Valid? → Instant start │
|
| 46 |
+
│ 4. Invalid → pure-HTTP re-capture: │
|
| 47 |
+
│ a. GET /playground/chat → session cookies │
|
| 48 |
+
│ b. read its JS chunks, regex out the action id │
|
| 49 |
+
│ BY NAME (robust to id-length changes) │
|
| 50 |
+
│ c. RSC POST (data="[]") → parse the token │
|
| 51 |
+
│ d. save cookies + action ids │
|
| 52 |
+
│ │
|
| 53 |
+
│ First run : ~2-5s (GET + chunk scan) │
|
| 54 |
+
│ Next runs : ~1-2s (cached credentials) │
|
| 55 |
+
└────────────────────────────────────────────────────────────┘
|
| 56 |
+
|
| 57 |
+
Usage:
|
| 58 |
+
from upstage_provider import UpstageProvider
|
| 59 |
+
|
| 60 |
+
up = UpstageProvider()
|
| 61 |
+
|
| 62 |
+
# plain strings (v1/v2 compatible):
|
| 63 |
+
async for token in up.chat(data="Hello!"):
|
| 64 |
+
print(token, end="", flush=True)
|
| 65 |
+
|
| 66 |
+
# typed, realtime events:
|
| 67 |
+
async for ev in up.stream(data="What is 2+2?"):
|
| 68 |
+
if ev.kind == "sources": print(ev.text)
|
| 69 |
+
elif ev.kind == "thinking": print(ev.text, end="") # dim
|
| 70 |
+
elif ev.kind == "content": print(ev.text, end="") # bright
|
| 71 |
+
|
| 72 |
+
# after a turn:
|
| 73 |
+
up.last_response # text content (no think markup)
|
| 74 |
+
up.last_reasoning # reasoning trace
|
| 75 |
+
up.last_sources # [{index,title,url}, ...]
|
| 76 |
+
up.last_usage # TurnUsage (timing, tokens, …)
|
| 77 |
+
up.session_usage.format_report() # the usage department
|
| 78 |
+
══════════════════════════════════════════════════════════════
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
from __future__ import annotations
|
| 82 |
+
|
| 83 |
+
import json
|
| 84 |
+
import os as _os
|
| 85 |
+
import re
|
| 86 |
+
import time
|
| 87 |
+
import uuid
|
| 88 |
+
from dataclasses import dataclass, field
|
| 89 |
+
from pathlib import Path
|
| 90 |
+
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
| 91 |
+
|
| 92 |
+
from curl_cffi import requests as _cffi
|
| 93 |
+
|
| 94 |
+
# ═══════════════════════════════════════════════════════════
|
| 95 |
+
# §1 — CONSTANTS
|
| 96 |
+
# ═══════════════════════════════════════════════════════════
|
| 97 |
+
_CONSOLE = "https://console.upstage.ai"
|
| 98 |
+
_API_HOST = "https://ap-northeast-2.apistage.ai"
|
| 99 |
+
_CHAT_EP = "/playground/chat"
|
| 100 |
+
_COMP_URL = f"{_API_HOST}/v1/web/demo/chat/completions?include_think=true"
|
| 101 |
+
|
| 102 |
+
def _cred_file() -> Path:
|
| 103 |
+
"""Resolved at call time so UPSTAGE_CACHE_DIR can change post-import."""
|
| 104 |
+
return Path(_os.getenv("UPSTAGE_CACHE_DIR", "/tmp/.cache/upstage")) / \
|
| 105 |
+
"upstage_creds.json"
|
| 106 |
+
|
| 107 |
+
_UA = (
|
| 108 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 109 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 110 |
+
"Chrome/146.0.0.0 Safari/537.36"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Action names to extract from the client JS bundles (stable across
|
| 114 |
+
# builds — only the 42-hex ids change on redeploys).
|
| 115 |
+
_ACTION_TOKEN = "getConsoleCsrfToken" # → {"token": "<jwt>"}
|
| 116 |
+
_ACTION_INIT = "authAction" # → flight "1:null" (kept for parity)
|
| 117 |
+
|
| 118 |
+
# Cap on how many JS chunks we scan during capture.
|
| 119 |
+
_MAX_CHUNK_SCAN = 80
|
| 120 |
+
|
| 121 |
+
_UA_HEADERS = {"User-Agent": _UA}
|
| 122 |
+
|
| 123 |
+
# (connect_timeout, read_timeout) — curl_cffi makes the total
|
| 124 |
+
# connect+read, so a 5-minute envelope is plenty for long generations.
|
| 125 |
+
_CONNECT_TIMEOUT = 15
|
| 126 |
+
_STREAM_TIMEOUT = 300
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _http_session() -> "_cffi.AsyncSession":
|
| 130 |
+
"""curl_cffi async session impersonating Chrome's TLS fingerprint."""
|
| 131 |
+
return _cffi.AsyncSession(impersonate="chrome")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ═══════════════════════════════════════════════════════════
|
| 135 |
+
# §2 — ERRORS
|
| 136 |
+
# ═══════════════════════════════════════════════════════════
|
| 137 |
+
class UpstageError(Exception):
|
| 138 |
+
"""Base class for all provider errors."""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class UpstageAuthError(UpstageError):
|
| 142 |
+
"""Credentials invalid / rejected — a re-capture was attempted."""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class UpstageStreamError(UpstageError):
|
| 146 |
+
"""The stream could not be established or died mid-flight."""
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ═══════════════════════════════════════════════════════════
|
| 150 |
+
# §3 — MODEL REGISTRY
|
| 151 |
+
# ═══════════════════════════════════════════════════════════
|
| 152 |
+
_MODELS: Dict[str, Dict[str, Any]] = {
|
| 153 |
+
"solar-pro3": {
|
| 154 |
+
"reasoning": ["low", "medium", "high"],
|
| 155 |
+
"search": True,
|
| 156 |
+
"system": "",
|
| 157 |
+
"temperature": 0.8,
|
| 158 |
+
"max_tokens": 65536,
|
| 159 |
+
"metadata": None,
|
| 160 |
+
},
|
| 161 |
+
"solar-pro2": {
|
| 162 |
+
"reasoning": ["low", "high"],
|
| 163 |
+
"search": True,
|
| 164 |
+
"system": "",
|
| 165 |
+
"temperature": 0.8,
|
| 166 |
+
"max_tokens": 16383,
|
| 167 |
+
"metadata": None,
|
| 168 |
+
},
|
| 169 |
+
"syn-pro": {
|
| 170 |
+
"reasoning": ["low", "high"],
|
| 171 |
+
"search": True,
|
| 172 |
+
"system": "",
|
| 173 |
+
"temperature": 0.7,
|
| 174 |
+
"max_tokens": 16384,
|
| 175 |
+
"metadata": {
|
| 176 |
+
"helpfulness": 4, "correctness": 4, "coherence": 4,
|
| 177 |
+
"complexity": 4, "verbosity": 4, "quality": 4,
|
| 178 |
+
"toxicity": 0, "humor": 0, "creativity": 0,
|
| 179 |
+
},
|
| 180 |
+
},
|
| 181 |
+
"upstage/solar-1-mini-chat": {
|
| 182 |
+
"reasoning": None,
|
| 183 |
+
"search": True,
|
| 184 |
+
"system": "",
|
| 185 |
+
"temperature": 0.8,
|
| 186 |
+
"max_tokens": 16383,
|
| 187 |
+
"metadata": None,
|
| 188 |
+
},
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
_MODEL_ALIASES: Dict[str, str] = {
|
| 192 |
+
"solar-pro3": "solar-pro3", "solar-pro2": "solar-pro2",
|
| 193 |
+
"syn-pro": "syn-pro",
|
| 194 |
+
"solar-1-mini-chat": "upstage/solar-1-mini-chat",
|
| 195 |
+
"upstage/solar-1-mini-chat": "upstage/solar-1-mini-chat",
|
| 196 |
+
"solar3": "solar-pro3", "solar2": "solar-pro2",
|
| 197 |
+
"pro3": "solar-pro3", "pro2": "solar-pro2",
|
| 198 |
+
"syn": "syn-pro", "mini": "upstage/solar-1-mini-chat",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _resolve_model(val: str) -> str:
|
| 203 |
+
if not val:
|
| 204 |
+
return "solar-pro3"
|
| 205 |
+
v = val.lower().strip()
|
| 206 |
+
if v in _MODEL_ALIASES:
|
| 207 |
+
return _MODEL_ALIASES[v]
|
| 208 |
+
if v in _MODELS:
|
| 209 |
+
return v
|
| 210 |
+
for name in _MODELS:
|
| 211 |
+
if v in name.lower():
|
| 212 |
+
return name
|
| 213 |
+
return val
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ════════════════════════════��══════════════════════════════
|
| 217 |
+
# §4 — SSE PARSER
|
| 218 |
+
# ═══════════════════════════════════════════════════════════
|
| 219 |
+
class _SSE:
|
| 220 |
+
"""
|
| 221 |
+
Parse Upstage SSE lines into a list of (event_type, content) tuples.
|
| 222 |
+
|
| 223 |
+
Event types:
|
| 224 |
+
"r-delta" — reasoning token (delta.reasoning_content)
|
| 225 |
+
"t-delta" — text/content token (delta.content)
|
| 226 |
+
"source" — search results JSON string
|
| 227 |
+
"s-start" — search started
|
| 228 |
+
"s-summary" — summarizing
|
| 229 |
+
"usage" — non-zero usage block from the API (JSON string)
|
| 230 |
+
"done" — stream finished (always last, if present)
|
| 231 |
+
|
| 232 |
+
A single line can produce several events (e.g. the final chunk
|
| 233 |
+
carries both `usage` and `finish_reason: stop`).
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
@staticmethod
|
| 237 |
+
def parse_line(line: str) -> List[Tuple[str, str]]:
|
| 238 |
+
if not line or not line.startswith("data: "):
|
| 239 |
+
return []
|
| 240 |
+
|
| 241 |
+
data = line[6:].strip()
|
| 242 |
+
if data == "[DONE]":
|
| 243 |
+
return [("done", "")]
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
obj = json.loads(data)
|
| 247 |
+
except json.JSONDecodeError:
|
| 248 |
+
return []
|
| 249 |
+
|
| 250 |
+
events: List[Tuple[str, str]] = []
|
| 251 |
+
|
| 252 |
+
# ── Search events (arrived without choices) ──
|
| 253 |
+
search = obj.get("search")
|
| 254 |
+
if obj.get("choices") is None and search:
|
| 255 |
+
st = search.get("status", {})
|
| 256 |
+
action = st.get("action", "")
|
| 257 |
+
desc = st.get("description", "")
|
| 258 |
+
raw_sq = search.get("search_queries")
|
| 259 |
+
|
| 260 |
+
if action == "search_start":
|
| 261 |
+
query = ""
|
| 262 |
+
if raw_sq and isinstance(raw_sq, list) and raw_sq:
|
| 263 |
+
query = raw_sq[0].get("query", "")
|
| 264 |
+
events.append(("s-start", query))
|
| 265 |
+
elif action == "search_finish":
|
| 266 |
+
if raw_sq:
|
| 267 |
+
events.append(("source", json.dumps(raw_sq)))
|
| 268 |
+
elif action == "summarizing":
|
| 269 |
+
events.append(("s-summary", desc))
|
| 270 |
+
else:
|
| 271 |
+
# ── Content / Thinking chunks ──
|
| 272 |
+
choices = obj.get("choices")
|
| 273 |
+
if choices:
|
| 274 |
+
delta = choices[0].get("delta", {})
|
| 275 |
+
finish = choices[0].get("finish_reason")
|
| 276 |
+
|
| 277 |
+
rc = delta.get("reasoning_content", "")
|
| 278 |
+
if rc:
|
| 279 |
+
events.append(("r-delta", rc))
|
| 280 |
+
|
| 281 |
+
text = delta.get("content", "")
|
| 282 |
+
if text:
|
| 283 |
+
# <think> splitting happens one level up
|
| 284 |
+
events.append(("t-delta", text))
|
| 285 |
+
|
| 286 |
+
# ── Usage (only when the API reports non-zero counts) ──
|
| 287 |
+
usage = obj.get("usage")
|
| 288 |
+
if isinstance(usage, dict) and (
|
| 289 |
+
usage.get("prompt_tokens") or usage.get("completion_tokens")
|
| 290 |
+
or usage.get("total_tokens")
|
| 291 |
+
):
|
| 292 |
+
events.append(("usage", json.dumps(usage)))
|
| 293 |
+
|
| 294 |
+
# ── done LAST so a same-line usage event is not skipped ──
|
| 295 |
+
if obj.get("choices") and obj["choices"][0].get("finish_reason") == "stop":
|
| 296 |
+
events.append(("done", ""))
|
| 297 |
+
|
| 298 |
+
return events
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ═══════════════════════════════════════════════════════════
|
| 302 |
+
# §5 — SOURCE FORMATTER
|
| 303 |
+
# ═══════════════════════════════════════════════════════════
|
| 304 |
+
class _Sources:
|
| 305 |
+
|
| 306 |
+
@staticmethod
|
| 307 |
+
def parse(raw_json_list: List[str]) -> List[Dict]:
|
| 308 |
+
seen_urls: set[str] = set()
|
| 309 |
+
result: List[Dict] = []
|
| 310 |
+
idx = 0
|
| 311 |
+
|
| 312 |
+
for raw in raw_json_list:
|
| 313 |
+
try:
|
| 314 |
+
queries = json.loads(raw) if isinstance(raw, str) else raw
|
| 315 |
+
except (json.JSONDecodeError, TypeError):
|
| 316 |
+
continue
|
| 317 |
+
|
| 318 |
+
if not isinstance(queries, list):
|
| 319 |
+
continue
|
| 320 |
+
|
| 321 |
+
for q_data in queries:
|
| 322 |
+
query_text = q_data.get("query", "")
|
| 323 |
+
raw_results = q_data.get("results", [])
|
| 324 |
+
|
| 325 |
+
if not isinstance(raw_results, list):
|
| 326 |
+
continue
|
| 327 |
+
|
| 328 |
+
for r in raw_results:
|
| 329 |
+
url = r.get("url", "").strip()
|
| 330 |
+
title = r.get("title", "").strip()
|
| 331 |
+
score = float(r.get("score", 0.0))
|
| 332 |
+
content = r.get("content", "").strip()
|
| 333 |
+
|
| 334 |
+
if not url or url in seen_urls:
|
| 335 |
+
continue
|
| 336 |
+
|
| 337 |
+
seen_urls.add(url)
|
| 338 |
+
idx += 1
|
| 339 |
+
|
| 340 |
+
snippet = content[:200].replace("\n", " ").strip()
|
| 341 |
+
if len(content) > 200:
|
| 342 |
+
snippet += "..."
|
| 343 |
+
|
| 344 |
+
result.append({
|
| 345 |
+
"index": idx,
|
| 346 |
+
"query": query_text,
|
| 347 |
+
"title": title or "Untitled",
|
| 348 |
+
"url": url,
|
| 349 |
+
"score": score,
|
| 350 |
+
"snippet": snippet,
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
+
return result
|
| 354 |
+
|
| 355 |
+
@staticmethod
|
| 356 |
+
def format_text(sources: List[Dict]) -> str:
|
| 357 |
+
if not sources:
|
| 358 |
+
return ""
|
| 359 |
+
lines = [
|
| 360 |
+
"",
|
| 361 |
+
" ┌─────────────────────────────────────────",
|
| 362 |
+
f" │ 📚 Sources ({len(sources)})",
|
| 363 |
+
]
|
| 364 |
+
for s in sources:
|
| 365 |
+
lines.append(f" │ [{s.get('index', '?')}] {s.get('title', '')}")
|
| 366 |
+
lines.append(f" │ {s.get('url', '')}")
|
| 367 |
+
if s.get("score"):
|
| 368 |
+
try:
|
| 369 |
+
lines.append(f" │ Score: {float(s['score']):.4f}")
|
| 370 |
+
except (TypeError, ValueError):
|
| 371 |
+
pass
|
| 372 |
+
lines.append(" └─────────────────────────────────────────")
|
| 373 |
+
return "\n".join(lines)
|
| 374 |
+
|
| 375 |
+
@staticmethod
|
| 376 |
+
def format_json(sources: List[Dict]) -> str:
|
| 377 |
+
"""Format sources as a JSON string for streaming."""
|
| 378 |
+
clean_sources = []
|
| 379 |
+
for src in sources:
|
| 380 |
+
clean_sources.append({
|
| 381 |
+
"title": src.get("title", ""),
|
| 382 |
+
"url": src.get("url", ""),
|
| 383 |
+
"score": src.get("score", 0),
|
| 384 |
+
})
|
| 385 |
+
return json.dumps({"sources": clean_sources})
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ═══════════════════════════════════════════════════════════
|
| 389 |
+
# §6 — THINK SPLITTER
|
| 390 |
+
# ═══════════════════════════════════════════════════════════
|
| 391 |
+
_OPEN = "<think>"
|
| 392 |
+
_CLOSE = "</think>"
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
class ThinkSplitter:
|
| 396 |
+
"""
|
| 397 |
+
The backend inlines thinking INSIDE content deltas as
|
| 398 |
+
<think>…</think> markup, and tags can be split across tokens.
|
| 399 |
+
Feed it raw content tokens; get back (kind, segment) pairs with
|
| 400 |
+
kind ∈ {"content", "thinking"} — each segment clean of markup.
|
| 401 |
+
|
| 402 |
+
A trailing partial tag (e.g. buffer ending in `<thi`) is held
|
| 403 |
+
back until the next feed() decides what it was; flush() releases
|
| 404 |
+
whatever remains when the stream ends.
|
| 405 |
+
"""
|
| 406 |
+
|
| 407 |
+
def __init__(self, open_tag: str = _OPEN, close_tag: str = _CLOSE):
|
| 408 |
+
self._open = open_tag
|
| 409 |
+
self._close = close_tag
|
| 410 |
+
self._buf = ""
|
| 411 |
+
self._in = False
|
| 412 |
+
|
| 413 |
+
def feed(self, text: str) -> List[Tuple[str, str]]:
|
| 414 |
+
out: List[Tuple[str, str]] = []
|
| 415 |
+
self._buf += text
|
| 416 |
+
while True:
|
| 417 |
+
if self._in:
|
| 418 |
+
j = self._buf.find(self._close)
|
| 419 |
+
if j == -1:
|
| 420 |
+
hold = len(self._close) - 1
|
| 421 |
+
if len(self._buf) > hold:
|
| 422 |
+
seg, self._buf = self._buf[:-hold], self._buf[-hold:]
|
| 423 |
+
else:
|
| 424 |
+
seg = ""
|
| 425 |
+
if seg:
|
| 426 |
+
out.append(("thinking", seg))
|
| 427 |
+
break
|
| 428 |
+
seg = self._buf[:j]
|
| 429 |
+
if seg:
|
| 430 |
+
out.append(("thinking", seg))
|
| 431 |
+
self._in = False
|
| 432 |
+
self._buf = self._buf[j + len(self._close):]
|
| 433 |
+
else:
|
| 434 |
+
i = self._buf.find(self._open)
|
| 435 |
+
if i == -1:
|
| 436 |
+
hold = len(self._open) - 1
|
| 437 |
+
if len(self._buf) > hold:
|
| 438 |
+
seg, self._buf = self._buf[:-hold], self._buf[-hold:]
|
| 439 |
+
else:
|
| 440 |
+
seg = ""
|
| 441 |
+
if seg:
|
| 442 |
+
out.append(("content", seg))
|
| 443 |
+
break
|
| 444 |
+
seg = self._buf[:i]
|
| 445 |
+
if seg:
|
| 446 |
+
out.append(("content", seg))
|
| 447 |
+
self._in = True
|
| 448 |
+
self._buf = self._buf[i + len(self._open):]
|
| 449 |
+
return out
|
| 450 |
+
|
| 451 |
+
def flush(self) -> List[Tuple[str, str]]:
|
| 452 |
+
"""Release anything still buffered (stream over)."""
|
| 453 |
+
if not self._buf:
|
| 454 |
+
return []
|
| 455 |
+
kind = "thinking" if self._in else "content"
|
| 456 |
+
seg, self._buf = self._buf, ""
|
| 457 |
+
return [(kind, seg)]
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# ═══════════════════════════════════════════════════════════
|
| 461 |
+
# §7 — USAGE DEPARTMENT
|
| 462 |
+
# ═══════════════════════════════════════════════════════════
|
| 463 |
+
@dataclass
|
| 464 |
+
class TurnUsage:
|
| 465 |
+
"""Measured stats for one chat turn."""
|
| 466 |
+
model: str = "solar-pro3"
|
| 467 |
+
ok: bool = True
|
| 468 |
+
error: str = ""
|
| 469 |
+
prompt_chars: int = 0
|
| 470 |
+
thinking_chars: int = 0
|
| 471 |
+
content_chars: int = 0
|
| 472 |
+
n_sources: int = 0
|
| 473 |
+
api_usage: Optional[Dict[str, Any]] = None # when non-zero
|
| 474 |
+
elapsed_s: float = 0.0
|
| 475 |
+
first_token_s: Optional[float] = None
|
| 476 |
+
|
| 477 |
+
# ── tokens: API-reported if present, else chars/4 estimate ──
|
| 478 |
+
@property
|
| 479 |
+
def tokens_estimated(self) -> bool:
|
| 480 |
+
return not (self.api_usage
|
| 481 |
+
and (self.api_usage.get("total_tokens") or 0) > 0)
|
| 482 |
+
|
| 483 |
+
@property
|
| 484 |
+
def tokens(self) -> int:
|
| 485 |
+
if not self.tokens_estimated:
|
| 486 |
+
return int(self.api_usage.get("total_tokens") or 0)
|
| 487 |
+
chars = self.thinking_chars + self.content_chars
|
| 488 |
+
# any non-empty response is at least one token
|
| 489 |
+
return max(1, round(chars / 4)) if chars > 0 else 0
|
| 490 |
+
|
| 491 |
+
@property
|
| 492 |
+
def tokens_per_s(self) -> Optional[float]:
|
| 493 |
+
if self.elapsed_s <= 0:
|
| 494 |
+
return None
|
| 495 |
+
return self.tokens / self.elapsed_s
|
| 496 |
+
|
| 497 |
+
def format_line(self) -> str:
|
| 498 |
+
bits = [f"⏱ {self.elapsed_s:.1f}s"]
|
| 499 |
+
if self.first_token_s is not None:
|
| 500 |
+
bits.append(f"first token {self.first_token_s:.2f}s")
|
| 501 |
+
if self.tokens:
|
| 502 |
+
est = " (est)" if self.tokens_estimated else ""
|
| 503 |
+
bits.append(f"~{self.tokens} tok{est}")
|
| 504 |
+
if self.tokens_per_s:
|
| 505 |
+
bits.append(f"{self.tokens_per_s:.0f} tok/s")
|
| 506 |
+
if self.thinking_chars:
|
| 507 |
+
bits.append(f"💭 {self.thinking_chars}c")
|
| 508 |
+
bits.append(self.model)
|
| 509 |
+
if self.n_sources:
|
| 510 |
+
bits.append(f"📚 {self.n_sources}")
|
| 511 |
+
if not self.ok:
|
| 512 |
+
bits.append(f"✗ {self.error[:40]}")
|
| 513 |
+
return " · ".join(bits)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
class SessionUsage:
|
| 517 |
+
"""Accumulates TurnUsage across a provider's lifetime."""
|
| 518 |
+
|
| 519 |
+
def __init__(self) -> None:
|
| 520 |
+
self.turns: List[TurnUsage] = []
|
| 521 |
+
|
| 522 |
+
def add(self, turn: TurnUsage) -> None:
|
| 523 |
+
self.turns.append(turn)
|
| 524 |
+
|
| 525 |
+
def clear(self) -> None:
|
| 526 |
+
self.turns.clear()
|
| 527 |
+
|
| 528 |
+
def totals(self) -> Dict[str, Any]:
|
| 529 |
+
ok = [t for t in self.turns if t.ok]
|
| 530 |
+
return {
|
| 531 |
+
"turns": len(self.turns),
|
| 532 |
+
"ok": len(ok),
|
| 533 |
+
"failed": len(self.turns) - len(ok),
|
| 534 |
+
"elapsed_s": round(sum(t.elapsed_s for t in ok), 2),
|
| 535 |
+
"tokens": sum(t.tokens for t in ok),
|
| 536 |
+
"thinking_chars": sum(t.thinking_chars for t in ok),
|
| 537 |
+
"content_chars": sum(t.content_chars for t in ok),
|
| 538 |
+
"sources": sum(t.n_sources for t in ok),
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
def format_report(self) -> str:
|
| 542 |
+
if not self.turns:
|
| 543 |
+
return " (no turns yet)"
|
| 544 |
+
lines = [
|
| 545 |
+
"",
|
| 546 |
+
" ┌──────────────────────────────────────────────────────────────────",
|
| 547 |
+
" │ 📊 Session usage",
|
| 548 |
+
" │ turn status time tokens thinking answer src model",
|
| 549 |
+
" │ ────── ───────── ────── ──────── ──────── ────── ─── ─────",
|
| 550 |
+
]
|
| 551 |
+
for i, t in enumerate(self.turns, 1):
|
| 552 |
+
status = "✓" if t.ok else f"✗ {t.error[:14]}"
|
| 553 |
+
lines.append(
|
| 554 |
+
f" │ {i:<6} {status:<11} {t.elapsed_s:>5.1f}s {t.tokens:>6} "
|
| 555 |
+
f"{t.thinking_chars:>6} {t.content_chars:>4} {t.n_sources:>2} {t.model}"
|
| 556 |
+
)
|
| 557 |
+
tt = self.totals()
|
| 558 |
+
lines.append(" │ ────── ───────── ────── ──────── ──────── ────── ─── ─────")
|
| 559 |
+
lines.append(
|
| 560 |
+
f" │ TOTAL {tt['turns']} turns ({tt['ok']} ok, {tt['failed']} failed) · "
|
| 561 |
+
f"{tt['elapsed_s']:.1f}s · ~{tt['tokens']} tokens · "
|
| 562 |
+
f"{tt['sources']} sources"
|
| 563 |
+
)
|
| 564 |
+
lines.append(" └──────────────────────────────────────────────────────────────────")
|
| 565 |
+
return "\n".join(lines)
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
# ═══════════════════════════════════════════════════════════
|
| 569 |
+
# §8 — STREAM EVENTS
|
| 570 |
+
# ═══════════════════════════════════════════════════════════
|
| 571 |
+
@dataclass
|
| 572 |
+
class StreamEvent:
|
| 573 |
+
"""One realtime piece of the response.
|
| 574 |
+
|
| 575 |
+
kind:
|
| 576 |
+
"sources" — search sources (JSON blob, streamed first)
|
| 577 |
+
"thinking" — reasoning token
|
| 578 |
+
"content" — answer token
|
| 579 |
+
"done" — stream finished (final event)
|
| 580 |
+
"""
|
| 581 |
+
kind: str
|
| 582 |
+
text: str = ""
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# ═��═════════════════════════════════════════════════════════
|
| 586 |
+
# §9 — CREDENTIAL MANAGER (pure HTTP, fully async)
|
| 587 |
+
# ═══════════════════════════════════════════════════════════
|
| 588 |
+
def _find_action_id(js_text: str, action_name: str) -> Optional[str]:
|
| 589 |
+
"""
|
| 590 |
+
Extract a Next.js server-action id from a client JS bundle by its
|
| 591 |
+
declared name. Matches minified patterns like:
|
| 592 |
+
|
| 593 |
+
createServerReference)("002f44cb…d5",x.callServer,void 0,x.findSourceMapURL,"getConsoleCsrfToken")
|
| 594 |
+
|
| 595 |
+
The regex pins the id to its OWN argument list (exactly 3 unquoted
|
| 596 |
+
args before the name), so when several actions are declared on one
|
| 597 |
+
line (very common after minification) ids can't be cross-wired.
|
| 598 |
+
No fixed length assumption (ids were 40 hex, now 42 — extract by
|
| 599 |
+
name so future format changes don't break capture).
|
| 600 |
+
"""
|
| 601 |
+
m = re.search(
|
| 602 |
+
r'createServerReference\)\("([a-f0-9]{32,80})"(?:,[^,\"]+){3},'
|
| 603 |
+
r'"' + re.escape(action_name) + r'"\)',
|
| 604 |
+
js_text,
|
| 605 |
+
)
|
| 606 |
+
return m.group(1) if m else None
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
class _Creds:
|
| 610 |
+
|
| 611 |
+
def __init__(self, path: Optional[Path] = None):
|
| 612 |
+
self.path = Path(path) if path else _cred_file()
|
| 613 |
+
self.action_init: Optional[str] = None
|
| 614 |
+
self.action_token: Optional[str] = None
|
| 615 |
+
self.cookies: Dict[str, str] = {}
|
| 616 |
+
self.session_id: str = str(uuid.uuid4())
|
| 617 |
+
|
| 618 |
+
# ── Load / Save / Clear (tiny file → plain sync I/O) ──
|
| 619 |
+
async def load(self) -> bool:
|
| 620 |
+
if not self.path.exists():
|
| 621 |
+
return False
|
| 622 |
+
try:
|
| 623 |
+
data = json.loads(self.path.read_text())
|
| 624 |
+
self.action_init = data.get("action_init")
|
| 625 |
+
self.action_token = data.get("action_token")
|
| 626 |
+
self.cookies = data.get("cookies", {})
|
| 627 |
+
self.session_id = self.cookies.get("session_id", str(uuid.uuid4()))
|
| 628 |
+
return bool(self.action_token)
|
| 629 |
+
except Exception:
|
| 630 |
+
return False
|
| 631 |
+
|
| 632 |
+
async def save(self):
|
| 633 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 634 |
+
data = {
|
| 635 |
+
"action_init": self.action_init,
|
| 636 |
+
"action_token": self.action_token,
|
| 637 |
+
"cookies": self.cookies,
|
| 638 |
+
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 639 |
+
}
|
| 640 |
+
self.path.write_text(json.dumps(data, indent=2))
|
| 641 |
+
|
| 642 |
+
async def clear(self):
|
| 643 |
+
try:
|
| 644 |
+
if self.path.exists():
|
| 645 |
+
self.path.unlink()
|
| 646 |
+
except Exception:
|
| 647 |
+
pass
|
| 648 |
+
|
| 649 |
+
# ── Verify (one RSC POST) ─────────────────────────────
|
| 650 |
+
async def verify(self) -> Optional[str]:
|
| 651 |
+
if not self.action_token:
|
| 652 |
+
return None
|
| 653 |
+
try:
|
| 654 |
+
async with _http_session() as http:
|
| 655 |
+
return await self._try_get_token(http)
|
| 656 |
+
except Exception:
|
| 657 |
+
return None
|
| 658 |
+
|
| 659 |
+
async def _rsc_post(self, http, action_id: str) -> str:
|
| 660 |
+
headers = {
|
| 661 |
+
"accept": "text/x-component",
|
| 662 |
+
"content-type": "text/plain;charset=UTF-8",
|
| 663 |
+
"next-action": action_id,
|
| 664 |
+
"origin": _CONSOLE,
|
| 665 |
+
"referer": f"{_CONSOLE}{_CHAT_EP}",
|
| 666 |
+
"User-Agent": _UA,
|
| 667 |
+
}
|
| 668 |
+
# NOTE: no next-router-state-tree needed — verified empirically
|
| 669 |
+
# that the token action answers with and without it.
|
| 670 |
+
r = await http.post(
|
| 671 |
+
f"{_CONSOLE}{_CHAT_EP}",
|
| 672 |
+
headers=headers, data="[]",
|
| 673 |
+
cookies=self.cookies, timeout=20,
|
| 674 |
+
)
|
| 675 |
+
r.raise_for_status()
|
| 676 |
+
return r.text
|
| 677 |
+
|
| 678 |
+
async def _try_get_token(self, http) -> Optional[str]:
|
| 679 |
+
try:
|
| 680 |
+
body = await self._rsc_post(http, self.action_token)
|
| 681 |
+
for line in body.strip().splitlines():
|
| 682 |
+
if '"token"' in line:
|
| 683 |
+
idx = line.index("{")
|
| 684 |
+
try:
|
| 685 |
+
return json.loads(line[idx:])["token"]
|
| 686 |
+
except Exception:
|
| 687 |
+
continue
|
| 688 |
+
except Exception:
|
| 689 |
+
pass
|
| 690 |
+
return None
|
| 691 |
+
|
| 692 |
+
# ── Capture via pure HTTP (async) ─────────────────────
|
| 693 |
+
async def capture(self):
|
| 694 |
+
"""
|
| 695 |
+
Browser-free credential capture:
|
| 696 |
+
|
| 697 |
+
1. GET /playground/chat → session cookies + chunk list
|
| 698 |
+
2. GET the RSC payload → additional chunk refs (ins.)
|
| 699 |
+
3. scan chunks for createServerReference("…","getConsoleCsrfToken")
|
| 700 |
+
4. RSC POST with that id → parse {"token": …}
|
| 701 |
+
5. store cookies + ids
|
| 702 |
+
"""
|
| 703 |
+
async with _http_session() as http:
|
| 704 |
+
# 1) page load — sets the session cookies we need
|
| 705 |
+
page = await http.get(f"{_CONSOLE}{_CHAT_EP}",
|
| 706 |
+
headers=_UA_HEADERS, timeout=20)
|
| 707 |
+
page.raise_for_status()
|
| 708 |
+
html = page.text
|
| 709 |
+
|
| 710 |
+
# 2) merge chunk refs from the page + its RSC payload
|
| 711 |
+
chunk_refs = sorted(set(re.findall(r'static/chunks/[^\\"\\s\],]+\.js', html)))
|
| 712 |
+
try:
|
| 713 |
+
rsc = await http.get(
|
| 714 |
+
f"{_CONSOLE}{_CHAT_EP}",
|
| 715 |
+
headers={**_UA_HEADERS, "RSC": "1"},
|
| 716 |
+
timeout=20,
|
| 717 |
+
)
|
| 718 |
+
if rsc.status_code == 200:
|
| 719 |
+
chunk_refs = sorted(set(chunk_refs) | set(
|
| 720 |
+
re.findall(r'static/chunks/[^\\"\\s\],]+\.js', rsc.text)))
|
| 721 |
+
except Exception:
|
| 722 |
+
pass # RSC scan is insurance only
|
| 723 |
+
|
| 724 |
+
# 3) scan chunks for the action ids (by name)
|
| 725 |
+
action_token: Optional[str] = None
|
| 726 |
+
action_init: Optional[str] = None
|
| 727 |
+
scanned = 0
|
| 728 |
+
for ref in chunk_refs[:_MAX_CHUNK_SCAN]:
|
| 729 |
+
scanned += 1
|
| 730 |
+
try:
|
| 731 |
+
r = await http.get(f"{_CONSOLE}/_next/{ref}",
|
| 732 |
+
headers=_UA_HEADERS, timeout=20)
|
| 733 |
+
except Exception:
|
| 734 |
+
continue
|
| 735 |
+
if r.status_code != 200:
|
| 736 |
+
continue
|
| 737 |
+
js = r.text
|
| 738 |
+
if action_token is None and _ACTION_TOKEN in js:
|
| 739 |
+
action_token = _find_action_id(js, _ACTION_TOKEN)
|
| 740 |
+
if action_init is None and _ACTION_INIT in js:
|
| 741 |
+
action_init = _find_action_id(js, _ACTION_INIT)
|
| 742 |
+
if action_token:
|
| 743 |
+
break
|
| 744 |
+
|
| 745 |
+
if not action_token:
|
| 746 |
+
raise RuntimeError(
|
| 747 |
+
f"Could not find '{_ACTION_TOKEN}' action in any of "
|
| 748 |
+
f"{scanned} scanned JS chunks — the console may have "
|
| 749 |
+
f"changed structure. Delete {_cred_file()} and retry."
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
# 4) prove the action works + harvest a first token
|
| 753 |
+
self._cookies_from(http)
|
| 754 |
+
self.action_token = action_token
|
| 755 |
+
self.action_init = action_init
|
| 756 |
+
body = await self._rsc_post(http, action_token)
|
| 757 |
+
if '"token"' not in body:
|
| 758 |
+
raise RuntimeError(
|
| 759 |
+
f"Token action '{action_token[:12]}…' answered but "
|
| 760 |
+
f"no token in response (len={len(body)})."
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
self.session_id = self.cookies.get("session_id", str(uuid.uuid4()))
|
| 764 |
+
await self.save()
|
| 765 |
+
|
| 766 |
+
def _cookies_from(self, http):
|
| 767 |
+
try:
|
| 768 |
+
d = http.cookies.get_dict()
|
| 769 |
+
if isinstance(d, dict) and d:
|
| 770 |
+
self.cookies = {str(k): str(v) for k, v in d.items()}
|
| 771 |
+
except Exception:
|
| 772 |
+
pass
|
| 773 |
+
if "session_id" not in self.cookies:
|
| 774 |
+
self.cookies["session_id"] = str(uuid.uuid4())
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
# ═══════════════════════════════════════════════════════════
|
| 778 |
+
# §10 — MAIN PROVIDER (fully async, realtime)
|
| 779 |
+
# ═══════════════════════════════════════════════════════════
|
| 780 |
+
class UpstageProvider:
|
| 781 |
+
"""
|
| 782 |
+
☀️ Upstage Solar — Async Reasoning Model Provider (v3)
|
| 783 |
+
|
| 784 |
+
4 models · Web search · Reasoning modes · Session usage stats
|
| 785 |
+
100% asyncio streaming over curl_cffi (Chrome TLS impersonation)
|
| 786 |
+
Credentials cached to disk for instant subsequent startups.
|
| 787 |
+
|
| 788 |
+
┌─────────────────────────────────────────────────────────┐
|
| 789 |
+
│ up = UpstageProvider() │
|
| 790 |
+
│ up = UpstageProvider(model="solar-pro3") │
|
| 791 |
+
│ │
|
| 792 |
+
│ # Plain strings (backward compatible): │
|
| 793 |
+
│ async for t in up.chat(data="Hello!"): │
|
| 794 |
+
│ print(t, end="") │
|
| 795 |
+
│ │
|
| 796 |
+
│ # Typed realtime events: │
|
| 797 |
+
│ async for ev in up.stream( │
|
| 798 |
+
│ messages=[ │
|
| 799 |
+
│ {"role":"system", "content":"You are Bob"}, │
|
| 800 |
+
│ {"role":"user", "content":"Name?"}, │
|
| 801 |
+
│ ], │
|
| 802 |
+
│ model="solar-pro2", │
|
| 803 |
+
│ search=True, │
|
| 804 |
+
│ ): │
|
| 805 |
+
│ if ev.kind == "thinking": print(ev.text, dim) │
|
| 806 |
+
│ elif ev.kind == "content": print(ev.text, bright) │
|
| 807 |
+
│ │
|
| 808 |
+
│ # After chat: │
|
| 809 |
+
│ up.last_response # text content │
|
| 810 |
+
│ up.last_reasoning # reasoning trace │
|
| 811 |
+
│ up.last_sources # [{index,title,url}, ...] │
|
| 812 |
+
│ up.last_sources_text # formatted text block │
|
| 813 |
+
│ up.last_usage # TurnUsage (time/tokens/ttfb) │
|
| 814 |
+
│ up.session_usage.format_report() # usage department │
|
| 815 |
+
└─────────────────────────────────────────────────────────┘
|
| 816 |
+
|
| 817 |
+
Priority:
|
| 818 |
+
messages given → data is IGNORED
|
| 819 |
+
messages empty → data is used
|
| 820 |
+
"""
|
| 821 |
+
|
| 822 |
+
def __init__(
|
| 823 |
+
self,
|
| 824 |
+
model: Optional[str] = None,
|
| 825 |
+
system: str = "",
|
| 826 |
+
search: bool = False,
|
| 827 |
+
max_tokens: Optional[int] = None,
|
| 828 |
+
temperature: Optional[float] = None,
|
| 829 |
+
):
|
| 830 |
+
self.model = _resolve_model(model) if model else "solar-pro3"
|
| 831 |
+
self.system = system
|
| 832 |
+
self.search = search
|
| 833 |
+
self.max_tokens = max_tokens
|
| 834 |
+
self.temperature = temperature
|
| 835 |
+
|
| 836 |
+
self.history: List[Dict] = []
|
| 837 |
+
self.last_response: str = ""
|
| 838 |
+
self.last_reasoning: str = ""
|
| 839 |
+
self.last_sources: List[Dict] = []
|
| 840 |
+
self.last_sources_text: str = ""
|
| 841 |
+
self.last_sources_json: str = ""
|
| 842 |
+
self.last_usage: Optional[TurnUsage] = None
|
| 843 |
+
|
| 844 |
+
self.session_usage = SessionUsage()
|
| 845 |
+
|
| 846 |
+
self._creds = _Creds()
|
| 847 |
+
self._connected = False
|
| 848 |
+
|
| 849 |
+
# ═══════════════════════════════════════════════════
|
| 850 |
+
# CONNECTION PIPELINE
|
| 851 |
+
# ═══════════════════════════════════════════════════
|
| 852 |
+
async def connect(self):
|
| 853 |
+
"""
|
| 854 |
+
Explicitly connect to Upstage.
|
| 855 |
+
Tries cached credentials first (instant).
|
| 856 |
+
Falls back to pure-HTTP re-capture if needed.
|
| 857 |
+
Called automatically on first chat()/stream().
|
| 858 |
+
"""
|
| 859 |
+
loaded = await self._creds.load()
|
| 860 |
+
|
| 861 |
+
if loaded:
|
| 862 |
+
token = await self._creds.verify()
|
| 863 |
+
if token:
|
| 864 |
+
self._connected = True
|
| 865 |
+
return
|
| 866 |
+
|
| 867 |
+
# Credentials expired or missing — re-capture over HTTP
|
| 868 |
+
await self._creds.capture()
|
| 869 |
+
self._connected = True
|
| 870 |
+
|
| 871 |
+
async def _ensure_connected(self):
|
| 872 |
+
if not self._connected:
|
| 873 |
+
await self.connect()
|
| 874 |
+
|
| 875 |
+
# ── Get CSRF token (with auto-refresh) ────────────
|
| 876 |
+
async def _get_csrf(self) -> str:
|
| 877 |
+
token = await self._creds.verify()
|
| 878 |
+
if token:
|
| 879 |
+
return token
|
| 880 |
+
|
| 881 |
+
# Refresh
|
| 882 |
+
await self._creds.capture()
|
| 883 |
+
token = await self._creds.verify()
|
| 884 |
+
if token:
|
| 885 |
+
return token
|
| 886 |
+
|
| 887 |
+
raise UpstageAuthError(
|
| 888 |
+
f"Could not obtain CSRF token. "
|
| 889 |
+
f"Try deleting {_cred_file()} and restarting."
|
| 890 |
+
)
|
| 891 |
+
|
| 892 |
+
# ── Payload builder (pure logic) ──────────────────
|
| 893 |
+
def _build_payload(
|
| 894 |
+
self,
|
| 895 |
+
messages: List[Dict],
|
| 896 |
+
model: str,
|
| 897 |
+
search: bool,
|
| 898 |
+
reasoning: Optional[str],
|
| 899 |
+
temperature: Optional[float],
|
| 900 |
+
max_tokens: Optional[int],
|
| 901 |
+
) -> Dict:
|
| 902 |
+
cfg = _MODELS.get(model, _MODELS["solar-pro3"])
|
| 903 |
+
temp = (
|
| 904 |
+
temperature if temperature is not None
|
| 905 |
+
else (self.temperature or cfg["temperature"])
|
| 906 |
+
)
|
| 907 |
+
tok = (
|
| 908 |
+
max_tokens if max_tokens is not None
|
| 909 |
+
else (self.max_tokens or cfg["max_tokens"])
|
| 910 |
+
)
|
| 911 |
+
|
| 912 |
+
msgs = [m.copy() for m in messages]
|
| 913 |
+
|
| 914 |
+
if not msgs or msgs[0].get("role") != "system":
|
| 915 |
+
sys_content = self.system or cfg["system"] or ""
|
| 916 |
+
if sys_content:
|
| 917 |
+
msgs.insert(0, {"role": "system", "content": sys_content})
|
| 918 |
+
|
| 919 |
+
if search and cfg["search"]:
|
| 920 |
+
for m in reversed(msgs):
|
| 921 |
+
if m["role"] == "user":
|
| 922 |
+
m["mode"] = ["search"]
|
| 923 |
+
break
|
| 924 |
+
|
| 925 |
+
payload: Dict = {
|
| 926 |
+
"conversation_id": str(uuid.uuid4()),
|
| 927 |
+
"stream": True,
|
| 928 |
+
"log_enabled": True,
|
| 929 |
+
"messages": msgs,
|
| 930 |
+
"model": model,
|
| 931 |
+
"temperature": temp,
|
| 932 |
+
"max_tokens": tok,
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
# Automatically set reasoning based on search:
|
| 936 |
+
# search on → HIGH reasoning · search off → LOW
|
| 937 |
+
if cfg.get("reasoning"):
|
| 938 |
+
valid_reasoning = cfg["reasoning"]
|
| 939 |
+
|
| 940 |
+
if search:
|
| 941 |
+
effort = ("high" if "high" in valid_reasoning
|
| 942 |
+
else valid_reasoning[-1])
|
| 943 |
+
else:
|
| 944 |
+
effort = ("low" if "low" in valid_reasoning
|
| 945 |
+
else valid_reasoning[0])
|
| 946 |
+
|
| 947 |
+
# Override with explicit reasoning if provided
|
| 948 |
+
if reasoning and reasoning in valid_reasoning:
|
| 949 |
+
effort = reasoning
|
| 950 |
+
|
| 951 |
+
payload["reasoning_effort"] = effort
|
| 952 |
+
|
| 953 |
+
if cfg.get("metadata"):
|
| 954 |
+
payload["metadata"] = cfg["metadata"]
|
| 955 |
+
|
| 956 |
+
if search and cfg["search"]:
|
| 957 |
+
payload["search_provider"] = "tavily"
|
| 958 |
+
|
| 959 |
+
return payload
|
| 960 |
+
|
| 961 |
+
# ═══════════════════════════════════════════════════
|
| 962 |
+
# RAW STREAM (native async curl_cffi — the realtime core)
|
| 963 |
+
# ═══════════════════════════════════════════════════
|
| 964 |
+
async def _stream_events(
|
| 965 |
+
self, payload: Dict
|
| 966 |
+
) -> AsyncGenerator[Tuple[str, str], None]:
|
| 967 |
+
"""
|
| 968 |
+
One attempt: POST the completion and yield (etype, econtent)
|
| 969 |
+
tuples the instant each SSE line arrives on the socket.
|
| 970 |
+
No threads, no queues, no polling — just aiter_lines().
|
| 971 |
+
"""
|
| 972 |
+
csrf = await self._get_csrf()
|
| 973 |
+
headers = {
|
| 974 |
+
"accept": "*/*",
|
| 975 |
+
"content-type": "application/json",
|
| 976 |
+
"origin": _CONSOLE,
|
| 977 |
+
"referer": f"{_CONSOLE}/",
|
| 978 |
+
"x-csrf-token": csrf,
|
| 979 |
+
"x-session-id": self._creds.session_id,
|
| 980 |
+
"x-upstage-logging-enabled": "true",
|
| 981 |
+
"User-Agent": _UA,
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
async with _http_session() as http:
|
| 985 |
+
r = await http.post(
|
| 986 |
+
_COMP_URL,
|
| 987 |
+
json=payload,
|
| 988 |
+
headers=headers,
|
| 989 |
+
cookies=dict(self._creds.cookies),
|
| 990 |
+
stream=True,
|
| 991 |
+
timeout=(_CONNECT_TIMEOUT, _STREAM_TIMEOUT),
|
| 992 |
+
)
|
| 993 |
+
try:
|
| 994 |
+
if r.status_code in (401, 403):
|
| 995 |
+
raise UpstageAuthError(
|
| 996 |
+
f"Auth error: HTTP {r.status_code}"
|
| 997 |
+
)
|
| 998 |
+
if r.status_code != 200:
|
| 999 |
+
raise UpstageStreamError(
|
| 1000 |
+
f"HTTP {r.status_code}: {r.text[:200]}"
|
| 1001 |
+
)
|
| 1002 |
+
|
| 1003 |
+
async for raw in r.aiter_lines():
|
| 1004 |
+
line = (raw.decode("utf-8", "replace")
|
| 1005 |
+
if isinstance(raw, (bytes, bytearray)) else raw)
|
| 1006 |
+
for ev in _SSE.parse_line(line):
|
| 1007 |
+
yield ev
|
| 1008 |
+
if ev[0] == "done":
|
| 1009 |
+
return
|
| 1010 |
+
finally:
|
| 1011 |
+
try:
|
| 1012 |
+
await r.aclose()
|
| 1013 |
+
except Exception:
|
| 1014 |
+
pass
|
| 1015 |
+
|
| 1016 |
+
async def _events_with_retry(
|
| 1017 |
+
self, payload: Dict
|
| 1018 |
+
) -> AsyncGenerator[Tuple[str, str], None]:
|
| 1019 |
+
"""
|
| 1020 |
+
_stream_events with one auth-retry: on UpstageAuthError
|
| 1021 |
+
(401/403 — happens before any chunk is yielded) re-capture
|
| 1022 |
+
credentials once and try again.
|
| 1023 |
+
"""
|
| 1024 |
+
for attempt in (1, 2):
|
| 1025 |
+
try:
|
| 1026 |
+
async for ev in self._stream_events(payload):
|
| 1027 |
+
yield ev
|
| 1028 |
+
return
|
| 1029 |
+
except UpstageAuthError:
|
| 1030 |
+
if attempt == 1:
|
| 1031 |
+
await self._creds.capture()
|
| 1032 |
+
continue
|
| 1033 |
+
raise
|
| 1034 |
+
|
| 1035 |
+
# ═══════════════════════════════════════════════════
|
| 1036 |
+
# ★ STREAM (typed, realtime)
|
| 1037 |
+
# ═══════════════════════════════════════════════════
|
| 1038 |
+
async def stream(
|
| 1039 |
+
self,
|
| 1040 |
+
data: Optional[str] = None,
|
| 1041 |
+
messages: Optional[List[Dict]] = None,
|
| 1042 |
+
model: Optional[str] = None,
|
| 1043 |
+
system: Optional[str] = None,
|
| 1044 |
+
reasoning: Optional[str] = None,
|
| 1045 |
+
search: Optional[bool] = None,
|
| 1046 |
+
max_tokens: Optional[int] = None,
|
| 1047 |
+
temperature: Optional[float] = None,
|
| 1048 |
+
) -> AsyncGenerator[StreamEvent, None]:
|
| 1049 |
+
"""
|
| 1050 |
+
Realtime async stream of the full response.
|
| 1051 |
+
|
| 1052 |
+
Args:
|
| 1053 |
+
data : Simple prompt (ignored if messages given)
|
| 1054 |
+
messages : OpenAI-style [{role, content}]
|
| 1055 |
+
model : Override model (solar-pro3/pro2/syn-pro/mini)
|
| 1056 |
+
system : Override system prompt
|
| 1057 |
+
reasoning : Reasoning effort (low/medium/high)
|
| 1058 |
+
search : Web search enabled
|
| 1059 |
+
max_tokens : Max output tokens
|
| 1060 |
+
temperature : Sampling temperature
|
| 1061 |
+
|
| 1062 |
+
Yields StreamEvent(kind, text), kind ∈
|
| 1063 |
+
"sources" — search sources JSON (first, when searching)
|
| 1064 |
+
"thinking" — reasoning tokens (dim-friendly)
|
| 1065 |
+
"content" — answer tokens
|
| 1066 |
+
"done" — final event
|
| 1067 |
+
|
| 1068 |
+
After iteration (even if abandoned early):
|
| 1069 |
+
.last_response / .last_reasoning / .last_sources
|
| 1070 |
+
.last_usage — TurnUsage
|
| 1071 |
+
.session_usage — accumulated usage department
|
| 1072 |
+
"""
|
| 1073 |
+
if not messages and not data:
|
| 1074 |
+
raise ValueError("Provide 'messages' or 'data'")
|
| 1075 |
+
|
| 1076 |
+
await self._ensure_connected()
|
| 1077 |
+
|
| 1078 |
+
# ── Resolve model ──────────────────────────────
|
| 1079 |
+
use_model = _resolve_model(model) if model else self.model
|
| 1080 |
+
|
| 1081 |
+
# ── Resolve options ────────────────────────────
|
| 1082 |
+
use_search = search if search is not None else self.search
|
| 1083 |
+
use_system = system if system is not None else self.system
|
| 1084 |
+
|
| 1085 |
+
# ── Build messages ─────────────────────────────
|
| 1086 |
+
if messages:
|
| 1087 |
+
# MESSAGES MODE — data is IGNORED
|
| 1088 |
+
clean: List[Dict] = []
|
| 1089 |
+
for msg in messages:
|
| 1090 |
+
role = msg.get("role", "")
|
| 1091 |
+
content = msg.get("content", "")
|
| 1092 |
+
if isinstance(content, list):
|
| 1093 |
+
content = " ".join(
|
| 1094 |
+
p.get("text", "") for p in content
|
| 1095 |
+
if p.get("type") == "text"
|
| 1096 |
+
)
|
| 1097 |
+
if role == "system":
|
| 1098 |
+
use_system = content
|
| 1099 |
+
elif role in ("user", "assistant"):
|
| 1100 |
+
clean.append({"role": role, "content": content})
|
| 1101 |
+
self.history = clean
|
| 1102 |
+
|
| 1103 |
+
send_msgs: List[Dict] = []
|
| 1104 |
+
if use_system:
|
| 1105 |
+
send_msgs.append({"role": "system", "content": use_system})
|
| 1106 |
+
send_msgs.extend(clean)
|
| 1107 |
+
|
| 1108 |
+
else:
|
| 1109 |
+
# DATA MODE — append to history
|
| 1110 |
+
self.history.append({"role": "user", "content": data})
|
| 1111 |
+
|
| 1112 |
+
send_msgs = []
|
| 1113 |
+
if use_system:
|
| 1114 |
+
send_msgs.append({"role": "system", "content": use_system})
|
| 1115 |
+
send_msgs.extend(self.history)
|
| 1116 |
+
|
| 1117 |
+
# ── Build payload ──────────────────────────────
|
| 1118 |
+
payload = self._build_payload(
|
| 1119 |
+
send_msgs, use_model, use_search,
|
| 1120 |
+
reasoning, temperature, max_tokens,
|
| 1121 |
+
)
|
| 1122 |
+
|
| 1123 |
+
# ── State for this turn ────────────────────────
|
| 1124 |
+
t0 = time.time()
|
| 1125 |
+
usage = TurnUsage(
|
| 1126 |
+
model=use_model,
|
| 1127 |
+
prompt_chars=len(json.dumps(send_msgs, ensure_ascii=False)),
|
| 1128 |
+
)
|
| 1129 |
+
splitter = ThinkSplitter()
|
| 1130 |
+
reasoning_parts: List[str] = []
|
| 1131 |
+
content_parts: List[str] = []
|
| 1132 |
+
raw_sources: List[str] = []
|
| 1133 |
+
sources_yielded = False
|
| 1134 |
+
first_token: Optional[float] = None
|
| 1135 |
+
completed = False
|
| 1136 |
+
|
| 1137 |
+
def _note(kind: str, seg: str):
|
| 1138 |
+
nonlocal first_token
|
| 1139 |
+
if first_token is None:
|
| 1140 |
+
first_token = time.time() - t0
|
| 1141 |
+
if kind == "thinking":
|
| 1142 |
+
reasoning_parts.append(seg)
|
| 1143 |
+
usage.thinking_chars += len(seg)
|
| 1144 |
+
else:
|
| 1145 |
+
content_parts.append(seg)
|
| 1146 |
+
usage.content_chars += len(seg)
|
| 1147 |
+
|
| 1148 |
+
try:
|
| 1149 |
+
async for etype, econtent in self._events_with_retry(payload):
|
| 1150 |
+
if etype == "done":
|
| 1151 |
+
break
|
| 1152 |
+
|
| 1153 |
+
elif etype == "source":
|
| 1154 |
+
raw_sources.append(econtent)
|
| 1155 |
+
if use_search and not sources_yielded and raw_sources:
|
| 1156 |
+
sources_yielded = True
|
| 1157 |
+
fmt = _Sources.parse(raw_sources)
|
| 1158 |
+
self.last_sources = fmt
|
| 1159 |
+
self.last_sources_json = _Sources.format_json(fmt)
|
| 1160 |
+
self.last_sources_text = _Sources.format_text(fmt)
|
| 1161 |
+
usage.n_sources = len(fmt)
|
| 1162 |
+
if first_token is None:
|
| 1163 |
+
first_token = time.time() - t0
|
| 1164 |
+
yield StreamEvent("sources", self.last_sources_json)
|
| 1165 |
+
|
| 1166 |
+
elif etype == "usage":
|
| 1167 |
+
try:
|
| 1168 |
+
u = json.loads(econtent)
|
| 1169 |
+
if isinstance(u, dict) and u:
|
| 1170 |
+
usage.api_usage = u
|
| 1171 |
+
except (json.JSONDecodeError, TypeError):
|
| 1172 |
+
pass
|
| 1173 |
+
|
| 1174 |
+
elif etype == "r-delta":
|
| 1175 |
+
_note("thinking", econtent)
|
| 1176 |
+
yield StreamEvent("thinking", econtent)
|
| 1177 |
+
|
| 1178 |
+
elif etype == "t-delta":
|
| 1179 |
+
for kind, seg in splitter.feed(econtent):
|
| 1180 |
+
_note(kind, seg)
|
| 1181 |
+
yield StreamEvent(kind, seg)
|
| 1182 |
+
|
| 1183 |
+
# s-start / s-summary: internal, not surfaced
|
| 1184 |
+
|
| 1185 |
+
# flush the splitter (stream finished cleanly)
|
| 1186 |
+
for kind, seg in splitter.flush():
|
| 1187 |
+
_note(kind, seg)
|
| 1188 |
+
yield StreamEvent(kind, seg)
|
| 1189 |
+
|
| 1190 |
+
completed = True # before the yield: a consumer that
|
| 1191 |
+
yield StreamEvent("done", "") # stops *on* done is a clean exit
|
| 1192 |
+
|
| 1193 |
+
except UpstageError as e:
|
| 1194 |
+
usage.ok = False
|
| 1195 |
+
usage.error = ("auth: " if isinstance(e, UpstageAuthError) else "") + str(e)[:160]
|
| 1196 |
+
raise
|
| 1197 |
+
except Exception as e:
|
| 1198 |
+
usage.ok = False
|
| 1199 |
+
usage.error = str(e)[:200]
|
| 1200 |
+
raise UpstageStreamError(f"stream failed: {e}") from e
|
| 1201 |
+
finally:
|
| 1202 |
+
# finalize state — runs on clean exit, early break,
|
| 1203 |
+
# GeneratorExit, and error paths alike
|
| 1204 |
+
if not completed:
|
| 1205 |
+
for kind, seg in splitter.flush():
|
| 1206 |
+
_note(kind, seg) # state only — no yield here
|
| 1207 |
+
if usage.ok and not usage.error:
|
| 1208 |
+
usage.ok = False
|
| 1209 |
+
usage.error = "stream interrupted"
|
| 1210 |
+
|
| 1211 |
+
usage.elapsed_s = time.time() - t0
|
| 1212 |
+
usage.first_token_s = first_token
|
| 1213 |
+
self.last_response = "".join(content_parts)
|
| 1214 |
+
self.last_reasoning = "".join(reasoning_parts)
|
| 1215 |
+
|
| 1216 |
+
if raw_sources:
|
| 1217 |
+
fmt = _Sources.parse(raw_sources)
|
| 1218 |
+
if fmt:
|
| 1219 |
+
self.last_sources = fmt
|
| 1220 |
+
self.last_sources_text = _Sources.format_text(fmt)
|
| 1221 |
+
self.last_sources_json = _Sources.format_json(fmt)
|
| 1222 |
+
usage.n_sources = len(fmt)
|
| 1223 |
+
|
| 1224 |
+
self.last_usage = usage
|
| 1225 |
+
self.session_usage.add(usage)
|
| 1226 |
+
|
| 1227 |
+
if usage.ok and self.last_response:
|
| 1228 |
+
self.history.append({
|
| 1229 |
+
"role": "assistant",
|
| 1230 |
+
"content": self.last_response,
|
| 1231 |
+
})
|
| 1232 |
+
|
| 1233 |
+
# ═══════════════════════════════════════════════════
|
| 1234 |
+
# ★ CHAT (plain-string convenience, v1/v2 contract)
|
| 1235 |
+
# ═══════════════════════════════════════════════════
|
| 1236 |
+
async def chat(
|
| 1237 |
+
self,
|
| 1238 |
+
data: Optional[str] = None,
|
| 1239 |
+
messages: Optional[List[Dict]] = None,
|
| 1240 |
+
model: Optional[str] = None,
|
| 1241 |
+
system: Optional[str] = None,
|
| 1242 |
+
reasoning: Optional[str] = None,
|
| 1243 |
+
search: Optional[bool] = None,
|
| 1244 |
+
max_tokens: Optional[int] = None,
|
| 1245 |
+
temperature: Optional[float] = None,
|
| 1246 |
+
) -> AsyncGenerator[str, None]:
|
| 1247 |
+
"""
|
| 1248 |
+
Async-stream tokens from Upstage Solar (plain strings).
|
| 1249 |
+
|
| 1250 |
+
Yields the sources JSON blob first (when searching), then
|
| 1251 |
+
reasoning + answer tokens in realtime. Identical contract to
|
| 1252 |
+
the v1/v2 chat() — built on top of stream() now.
|
| 1253 |
+
"""
|
| 1254 |
+
async for ev in self.stream(
|
| 1255 |
+
data=data, messages=messages, model=model, system=system,
|
| 1256 |
+
reasoning=reasoning, search=search, max_tokens=max_tokens,
|
| 1257 |
+
temperature=temperature,
|
| 1258 |
+
):
|
| 1259 |
+
yield ev.text
|
| 1260 |
+
|
| 1261 |
+
# ═══════════════════════════════════════════════════
|
| 1262 |
+
# SETTERS (chainable)
|
| 1263 |
+
# ═══════════════════════════════════════════════════
|
| 1264 |
+
def set_model(self, model: str) -> "UpstageProvider":
|
| 1265 |
+
self.model = _resolve_model(model)
|
| 1266 |
+
return self
|
| 1267 |
+
|
| 1268 |
+
def set_system(self, prompt: str) -> "UpstageProvider":
|
| 1269 |
+
self.system = prompt
|
| 1270 |
+
return self
|
| 1271 |
+
|
| 1272 |
+
def set_search(self, enabled: bool) -> "UpstageProvider":
|
| 1273 |
+
self.search = enabled
|
| 1274 |
+
return self
|
| 1275 |
+
|
| 1276 |
+
def set_temperature(self, t: float) -> "UpstageProvider":
|
| 1277 |
+
self.temperature = t
|
| 1278 |
+
return self
|
| 1279 |
+
|
| 1280 |
+
def set_max_tokens(self, n: int) -> "UpstageProvider":
|
| 1281 |
+
self.max_tokens = n
|
| 1282 |
+
return self
|
| 1283 |
+
|
| 1284 |
+
# ── History ──────────────────────────────────────────
|
| 1285 |
+
def clear_history(self):
|
| 1286 |
+
self.history.clear()
|
| 1287 |
+
|
| 1288 |
+
def get_history(self) -> List[Dict]:
|
| 1289 |
+
out: List[Dict] = []
|
| 1290 |
+
if self.system:
|
| 1291 |
+
out.append({"role": "system", "content": self.system})
|
| 1292 |
+
out.extend(self.history)
|
| 1293 |
+
return out
|
| 1294 |
+
|
| 1295 |
+
def get_sources(self) -> List[Dict]:
|
| 1296 |
+
return self.last_sources
|
| 1297 |
+
|
| 1298 |
+
def get_sources_text(self) -> str:
|
| 1299 |
+
return self.last_sources_text
|
| 1300 |
+
|
| 1301 |
+
def get_sources_json(self) -> str:
|
| 1302 |
+
return self.last_sources_json
|
| 1303 |
+
|
| 1304 |
+
def new_session(self):
|
| 1305 |
+
self.history.clear()
|
| 1306 |
+
self.last_response = ""
|
| 1307 |
+
self.last_reasoning = ""
|
| 1308 |
+
self.last_sources = []
|
| 1309 |
+
self.last_sources_text = ""
|
| 1310 |
+
self.last_sources_json = ""
|
| 1311 |
+
self.last_usage = None
|
| 1312 |
+
self.session_usage.clear()
|
| 1313 |
+
|
| 1314 |
+
async def refresh_credentials(self):
|
| 1315 |
+
"""Force re-capture credentials (pure HTTP, ~2-5s)."""
|
| 1316 |
+
await self._creds.capture()
|
| 1317 |
+
|
| 1318 |
+
@staticmethod
|
| 1319 |
+
async def clear_credentials():
|
| 1320 |
+
"""Delete cached credentials file."""
|
| 1321 |
+
creds = _Creds()
|
| 1322 |
+
await creds.clear()
|
| 1323 |
+
|
| 1324 |
+
# ═══════════════════════════════════════════════════
|
| 1325 |
+
# INFO
|
| 1326 |
+
# ═══════════════════════════════════════════════════
|
| 1327 |
+
def list_models(self) -> List[Dict]:
|
| 1328 |
+
return [{
|
| 1329 |
+
"name": name,
|
| 1330 |
+
"short": name.split("/")[-1],
|
| 1331 |
+
"reasoning": cfg.get("reasoning"),
|
| 1332 |
+
"search": cfg.get("search", False),
|
| 1333 |
+
"max_tokens": cfg.get("max_tokens"),
|
| 1334 |
+
"active": name == self.model,
|
| 1335 |
+
} for name, cfg in _MODELS.items()]
|
| 1336 |
+
|
| 1337 |
+
@staticmethod
|
| 1338 |
+
def available_models() -> List[str]:
|
| 1339 |
+
return list(_MODELS.keys())
|
| 1340 |
+
|
| 1341 |
+
@staticmethod
|
| 1342 |
+
def model_info() -> Dict:
|
| 1343 |
+
return {
|
| 1344 |
+
"name": "Upstage Solar",
|
| 1345 |
+
"provider": "Upstage AI",
|
| 1346 |
+
"models": list(_MODELS.keys()),
|
| 1347 |
+
"thinking": True,
|
| 1348 |
+
"search": True,
|
| 1349 |
+
}
|
| 1350 |
+
|
| 1351 |
+
# ═══════════════════════════════════════════════════
|
| 1352 |
+
# LIFECYCLE
|
| 1353 |
+
# ═══════════════════════════════════════════════════
|
| 1354 |
+
async def close(self):
|
| 1355 |
+
pass # sessions are per-call — nothing persistent to close
|
| 1356 |
+
|
| 1357 |
+
async def __aenter__(self):
|
| 1358 |
+
return self
|
| 1359 |
+
|
| 1360 |
+
async def __aexit__(self, *_):
|
| 1361 |
+
await self.close()
|
| 1362 |
+
|
| 1363 |
+
def __repr__(self):
|
| 1364 |
+
s = "🔍" if self.search else ""
|
| 1365 |
+
r = "💭high" if self.search else "💭low"
|
| 1366 |
+
flags = f" {s}{r}".rstrip()
|
| 1367 |
+
return (
|
| 1368 |
+
f"UpstageProvider("
|
| 1369 |
+
f"model={self.model!r}, "
|
| 1370 |
+
f"via={'cached' if self._connected else 'not-connected'}, "
|
| 1371 |
+
f"history={len(self.history)}, "
|
| 1372 |
+
f"turns={self.session_usage.totals()['turns']}"
|
| 1373 |
+
f"{flags})"
|
| 1374 |
+
)
|