Adarshu07 commited on
Commit
f45a595
·
verified ·
1 Parent(s): b0df81d

Upload 20 files

Browse files
.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
+ )