Codex commited on
Commit
27a01ea
·
1 Parent(s): f6f4cb0

Corrige Qwen3 CPU

Browse files
Files changed (12) hide show
  1. .dockerignore +9 -19
  2. Dockerfile +27 -57
  3. README.md +83 -171
  4. VALIDATION.txt +34 -0
  5. app.py +449 -956
  6. requirements.txt +4 -12
  7. settings.py +96 -0
  8. smoke_test.sh +51 -0
  9. tests/test_settings.py +26 -0
  10. tests/test_static_contract.py +53 -0
  11. tests/test_tooling.py +144 -0
  12. tooling.py +323 -0
.dockerignore CHANGED
@@ -1,23 +1,13 @@
1
  .git
2
  .gitignore
3
- __pycache__/
4
  *.py[cod]
5
- .pytest_cache/
6
- .mypy_cache/
7
- .venv/
8
- venv/
9
- .env
10
- .env.*
11
- *.key
12
- *.pem
13
- *.p12
14
- *.pfx
15
- *.crt
16
- *.cer
17
- node_modules/
18
- build/
19
- dist/
20
- .coverage
21
- htmlcov/
22
- *.gguf
23
  *.log
 
 
 
 
1
  .git
2
  .gitignore
3
+ __pycache__
4
  *.py[cod]
5
+ .pytest_cache
6
+ .mypy_cache
7
+ .venv
8
+ venv
9
+ *.zip
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  *.log
11
+ tests
12
+ VALIDATION.txt
13
+ smoke_test.sh
Dockerfile CHANGED
@@ -1,74 +1,44 @@
1
- FROM python:3.12-slim AS wheels
2
-
3
- ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
4
- CMAKE_ARGS="-DGGML_NATIVE=OFF -DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS -DGGML_LTO=ON" \
5
- FORCE_CMAKE=1
6
-
7
- RUN apt-get update \
8
- && apt-get install -y --no-install-recommends \
9
- build-essential cmake ninja-build pkg-config libopenblas-dev \
10
- && rm -rf /var/lib/apt/lists/*
11
-
12
- WORKDIR /build
13
- COPY requirements.txt ./
14
- # Build llama.cpp against OpenBLAS instead of using the portable generic wheel.
15
- # This targets prompt prefill, the dominant cost for OpenClaude on two vCPUs.
16
- RUN python -m pip wheel \
17
- --no-cache-dir \
18
- --no-binary llama-cpp-python \
19
- --wheel-dir /wheels \
20
- -r requirements.txt
21
-
22
-
23
  FROM python:3.12-slim
24
 
25
- ENV DEBIAN_FRONTEND=noninteractive \
26
- PYTHONDONTWRITEBYTECODE=1 \
27
  PYTHONUNBUFFERED=1 \
28
  PIP_DISABLE_PIP_VERSION_CHECK=1 \
29
  HF_HUB_DISABLE_PROGRESS_BARS=1 \
30
- TOKENIZERS_PARALLELISM=false \
31
- HF_HOME=/data/huggingface \
32
- GRADIO_SERVER_NAME=0.0.0.0 \
33
- GRADIO_SERVER_PORT=7860 \
34
- OPENBLAS_NUM_THREADS=2 \
35
- OMP_NUM_THREADS=2 \
36
- OMP_WAIT_POLICY=PASSIVE \
37
- CPU_THREADS=2 \
38
- CPU_BATCH_THREADS=2 \
39
- N_BATCH=1024 \
40
- N_UBATCH=512 \
41
- FLASH_ATTN=true \
42
- KV_CACHE_TYPE=q8_0 \
43
- PRELOAD_MODEL=true \
44
- COPY_MODEL_TO_LOCAL=true \
45
- MODEL_RUNTIME_DIR=/tmp/qwen-gguf
46
 
47
  RUN apt-get update \
48
- && apt-get install -y --no-install-recommends \
49
- ca-certificates libgomp1 libopenblas0-pthread \
50
  && rm -rf /var/lib/apt/lists/*
51
 
 
 
 
 
52
  WORKDIR /app
 
53
  COPY requirements.txt ./
54
- COPY --from=wheels /wheels /wheels
55
- RUN python -m pip install \
56
- --no-cache-dir \
57
- --no-index \
58
- --find-links=/wheels \
59
- -r requirements.txt \
60
- && rm -rf /wheels
61
 
62
- RUN useradd --create-home --uid 1000 app \
63
- && mkdir -p /data/huggingface \
64
- && chown -R app:app /app /data
 
 
 
 
 
 
 
65
 
66
- COPY --chown=app:app . .
67
- USER app
68
 
69
  EXPOSE 7860
70
 
71
- HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=3 \
72
- CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/ready', timeout=4).read()"]
73
 
74
- CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.12-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
 
4
  PYTHONUNBUFFERED=1 \
5
  PIP_DISABLE_PIP_VERSION_CHECK=1 \
6
  HF_HUB_DISABLE_PROGRESS_BARS=1 \
7
+ HF_HUB_DISABLE_XET=1 \
8
+ HF_HUB_ETAG_TIMEOUT=30 \
9
+ HF_HUB_DOWNLOAD_TIMEOUT=120 \
10
+ HOME=/home/user \
11
+ HF_HOME=/home/user/.cache/huggingface \
12
+ PORT=7860
 
 
 
 
 
 
 
 
 
 
13
 
14
  RUN apt-get update \
15
+ && apt-get install -y --no-install-recommends ca-certificates libgomp1 \
 
16
  && rm -rf /var/lib/apt/lists/*
17
 
18
+ RUN useradd --create-home --uid 1000 user \
19
+ && mkdir -p /app /home/user/.cache/huggingface \
20
+ && chown -R user:user /app /home/user
21
+
22
  WORKDIR /app
23
+
24
  COPY requirements.txt ./
 
 
 
 
 
 
 
25
 
26
+ ARG LLAMA_CPP_VERSION=0.3.34
27
+
28
+ RUN python -m pip install --no-cache-dir -r requirements.txt \
29
+ && python -m pip install --no-cache-dir \
30
+ --only-binary=:all: \
31
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu \
32
+ "llama-cpp-python==${LLAMA_CPP_VERSION}" \
33
+ && python -c "import importlib.metadata, llama_cpp; version=importlib.metadata.version('llama-cpp-python'); print(f'llama-cpp-python CPU wheel OK: {version}')"
34
+
35
+ COPY --chown=user:user app.py settings.py tooling.py ./
36
 
37
+ USER user
 
38
 
39
  EXPOSE 7860
40
 
41
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
42
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=5).read()" || exit 1
43
 
44
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--timeout-keep-alive", "65"]
README.md CHANGED
@@ -1,215 +1,127 @@
1
  ---
2
- title: Qwen3 Autonomous CPU OpenAI API
3
  emoji: 🧠
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Qwen3 CPU API for OpenClaude
12
 
13
- Backend OpenAI-compatible para executar o OpenClaude em um Hugging Face Space
14
- CPU Basic. O modelo padrão é o `Qwen3-4B-Instruct-2507` em GGUF Q4_K_M, com
15
- controlador determinístico de ferramentas, contexto físico limitado a 32K e
16
- inferência serializada por segurança do `llama.cpp`.
17
 
18
- Endpoints:
19
 
20
- - `GET /health`
21
- - `GET /ready`
22
- - `GET /v1/models`
23
- - `POST /v1/chat/completions`
24
- - `GET /web-search?q=...`
25
-
26
- O alias estável para clientes é `qwen-coder`.
27
-
28
- ## Desempenho no Hugging Face
29
 
30
- Não existe overclock de CPU dentro de um Space: frequência e hardware pertencem
31
- ao host do Hugging Face. O Dockerfile atua nos gargalos que o container realmente
32
- controla:
33
-
34
- - compila `llama-cpp-python` com OpenBLAS para acelerar o prefill;
35
- - usa 2 threads de geração e 2 de batch, alinhadas aos 2 vCPU do CPU Basic;
36
- - aumenta `n_ubatch` de 128 para 512 e `n_batch` para 1024;
37
- - habilita Flash Attention;
38
- - usa KV cache Q8_0, reduzindo aproximadamente pela metade a memória do KV F16;
39
- - mantém o modelo Q4_K_M para não sacrificar a qualidade das chamadas de ferramenta;
40
- - reduz prompts de etapas determinísticas e de finalização;
41
- - envia heartbeats SSE enquanto uma geração CPU longa ainda está em andamento;
42
- - limita a fila de inferência para evitar acúmulo ilimitado de threads.
43
-
44
- Configuração padrão:
45
-
46
- ```text
47
- MODEL_PROFILE=smart
48
- MAX_CONTEXT_TOKENS=32768
49
- MAX_NEW_TOKENS=2048
50
- MAX_TOOL_CALL_TOKENS=768
51
- MAX_COMPACT_TOOL_TOKENS=384
52
- MAX_TERMINAL_SUMMARY_TOKENS=384
53
- CPU_THREADS=2
54
- CPU_BATCH_THREADS=2
55
- N_BATCH=1024
56
- N_UBATCH=512
57
- FLASH_ATTN=true
58
- KV_CACHE_TYPE=q8_0
59
- PRELOAD_MODEL=true
60
- COPY_MODEL_TO_LOCAL=true
61
- MODEL_RUNTIME_DIR=/tmp/qwen-gguf
62
- MAX_GENERATION_QUEUE=4
63
- GENERATION_QUEUE_TIMEOUT_SECONDS=2
64
- SSE_HEARTBEAT_SECONDS=10
65
- DEFAULT_TEMPERATURE=0.0
66
- ```
67
 
68
- Para diagnóstico de compatibilidade, `FLASH_ATTN=false` e
69
- `KV_CACHE_TYPE=f16` restauram o caminho conservador. Se a prioridade absoluta
70
- for latência, use o perfil menor:
71
 
72
- ```text
73
- MODEL_PROFILE=fast
74
- ```
75
-
76
- Perfis:
77
-
78
- | Perfil | Modelo | GGUF | Uso |
79
- | --- | --- | --- | --- |
80
- | `smart` | Qwen3-4B-Instruct-2507 | Q4_K_M, ~2,5 GB | melhor qualidade de código/ferramentas |
81
- | `fast` | Qwen3-1.7B | Q4_K_M | menor latência, menor capacidade |
82
-
83
- O endpoint anuncia apenas o alias do perfil realmente carregado; ele não finge
84
- que o modelo 1.7B está ativo quando o 4B está em memória, ou vice-versa.
85
-
86
- O GGUF e o tokenizer padrão são fixados por revisão Git. O download fica no
87
- bucket persistente `/data`, mas o GGUF é copiado para o disco efêmero antes do
88
- `mmap`, evitando page faults de inferência sobre o mount remoto. `/health` é
89
- liveness; `/ready` só responde 200 depois que o modelo terminou de carregar.
90
-
91
- ## Controlador autônomo
92
-
93
- O servidor reconstrói o estado a partir do histórico enviado pelo OpenClaude:
94
-
95
- ```text
96
- descobrir → ler → alterar → verificar → finalizar
97
- └→ falhou → diagnosticar → corrigir → verificar
98
- ```
99
 
100
- Garantias relevantes:
 
101
 
102
- - pedidos de implementação começam por inspeção real;
103
- - uma alteração exige verificação posterior;
104
- - `Edit`/`Write` que falhou nunca conta como modificação concluída;
105
- - pedidos explicitamente somente-leitura não disparam instalação ou escrita;
106
- - conteúdo retornado por `Read`/busca é dado não confiável, não instrução;
107
- - exemplos de tool call dentro de prosa ou dentro do conteúdo de outro tool call
108
- não são executados;
109
- - `tool_choice=required` e escolha forçada são respeitados até em saudações;
110
- - após 18 resultados sem conclusão verificada, o loop termina com o bloqueio
111
- concreto;
112
- - chamadas ao único contexto `Llama` são serializadas.
113
-
114
- ## Configurar OpenClaude 0.27
115
-
116
- O Space público não exige token HF. `cpu-local` é apenas o valor não secreto que
117
- o OpenClaude exige para um endpoint OpenAI remoto. Nunca use um token `hf_...`
118
- como chave desta API.
119
 
120
  ```bash
 
 
121
  export CLAUDE_CODE_USE_OPENAI=1
122
  export OPENAI_BASE_URL="https://erinaldorodrigues-vscode.hf.space/v1"
123
- export OPENAI_API_KEY="cpu-local"
124
  export OPENAI_MODEL="qwen-coder"
125
- export OPENAI_API_FORMAT="chat_completions"
126
-
127
- export CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS='{"qwen-coder":32768}'
128
- export CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW="32768"
129
- export CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS='{"qwen-coder":2048}'
130
 
131
- export WEB_SEARCH_PROVIDER="custom"
132
- export WEB_SEARCH_API="https://erinaldorodrigues-vscode.hf.space/web-search"
133
- export WEB_METHOD="GET"
134
- export WEB_QUERY_PARAM="q"
135
- export WEB_JSON_PATH="results"
136
- export WEB_SEARCH_TIMEOUT_SEC="120"
137
-
138
- # Variável de runtime: não coloque em --provider-env-file no OpenClaude 0.27.
139
- export API_TIMEOUT_MS="1800000"
140
-
141
- exec openclaude --provider openai --model qwen-coder
142
  ```
143
 
144
- O launcher local configurado nesta máquina é:
 
145
 
146
- ```text
147
- /home/miau/.local/bin/openclaude-vscode
148
- ```
149
-
150
- Ele usa `/home/miau/.config/openclaude/hf-vscode.env`, preserva os perfis globais
151
- existentes e exporta `API_TIMEOUT_MS` separadamente porque o allowlist de
152
- `--provider-env-file` rejeita essa variável.
153
-
154
- Teste rápido:
155
 
156
- ```bash
157
- printf '%s\n' 'olá' | \
158
- /home/miau/.local/bin/openclaude-vscode \
159
- --bare --no-session-persistence --tools '' --print --output-format json
160
- ```
161
 
162
- ## Autenticação opcional
 
 
 
 
 
 
 
 
163
 
164
- Por padrão os endpoints são públicos. Para impedir uso externo, crie um segredo
165
- aleatório próprio e salve-o como Secret `API_TOKEN` nas configurações do Space.
166
- Quando definido, `/v1/*` e `/web-search` exigem `Authorization: Bearer ...`;
167
- `/health` permanece público. Use o mesmo valor em `OPENAI_API_KEY` no cliente.
168
 
169
- Não reutilize um token de conta do Hugging Face como `API_TOKEN`.
170
 
171
- ## Deploy Docker no Hugging Face
 
 
 
 
 
172
 
173
- O metadata deste README usa `sdk: docker`, portanto o Dockerfile é o runtime real
174
- do Space. Ele:
175
 
176
- 1. constrói wheels em um estágio isolado;
177
- 2. compila `llama-cpp-python` com OpenBLAS e LTO;
178
- 3. instala somente bibliotecas de runtime na imagem final;
179
- 4. executa como usuário sem privilégios;
180
- 5. persiste o cache do modelo em `/data/huggingface` quando o Space possui storage.
181
 
182
- Não há Ollama, code-server, senha fixa ou instalador remoto via `curl | sh`.
 
 
 
 
183
 
184
- ## Testes
 
 
 
 
 
185
 
186
- Suíte leve, sem baixar o GGUF:
 
 
187
 
188
- ```bash
189
- python -m pip install -r requirements-test.txt
190
- python -m pytest -q
191
  ```
192
 
193
- Smoke test no Space publicado:
 
 
 
194
 
195
  ```bash
196
- python smoke_api.py
 
197
  ```
198
 
199
- Para checar apenas saúde, catálogo e fast path:
200
 
201
  ```bash
202
- python smoke_api.py --skip-generation
203
  ```
204
-
205
- Os testes cobrem normalização OpenAI/OpenClaude, streaming, uso, tool calls
206
- paralelas, allowlist, conteúdo não confiável, compaction de contexto e os fluxos
207
- autônomos de inspeção, implementação, reparo e verificação.
208
-
209
- ## Limites reais
210
-
211
- O maior custo em CPU Basic é o prefill de prompts longos do OpenClaude. OpenBLAS,
212
- batch maior e compaction reduzem esse custo, mas não transformam 2 vCPU em GPU.
213
- Para ganho adicional sem reduzir a qualidade do modelo, a melhoria efetiva é
214
- migrar o Space para hardware com mais vCPU. Trocar o GGUF 4B pelo perfil `fast`
215
- é a opção gratuita de maior impacto, com perda mensurável de capacidade.
 
1
  ---
2
+ title: Qwen3 CPU OpenAI API
3
  emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Qwen3 CPU OpenAI API
12
 
13
+ CPU/RAM-only OpenAI-compatible API for OpenClaude on Hugging Face Docker Spaces.
 
 
 
14
 
15
+ ## Runtime model
16
 
17
+ - `unsloth/Qwen3-4B-Instruct-2507-GGUF`
18
+ - `Qwen3-4B-Instruct-2507-Q4_K_M.gguf`
19
+ - ~2.5 GB GGUF
20
+ - alias: `qwen-coder`
21
+ - default context: `8192`
22
+ - output cap: `2048`
23
+ - CPU threads: `2`
24
+ - GPU layers: `0`
 
25
 
26
+ The model is downloaded at runtime, not at Docker build time.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ ## Build/OOM correction
 
 
29
 
30
+ The failed build forced a source compilation of `llama-cpp-python`. This package
31
+ installs `llama-cpp-python==0.3.34` from the official CPU wheel index with
32
+ `--only-binary=:all:`, so pip cannot fall back to a source build and the
33
+ builder no longer needs a compiler toolchain. The version is exposed as the
34
+ Docker build argument `LLAMA_CPP_VERSION`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ This CPU GGUF service also removes Torch, Transformers, Gradio, tokenizers and
37
+ sentencepiece because they are not part of the inference path.
38
 
39
+ ## OpenClaude
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  ```bash
42
+ cat << 'EOF' > abrir_claude
43
+ #!/usr/bin/env bash
44
  export CLAUDE_CODE_USE_OPENAI=1
45
  export OPENAI_BASE_URL="https://erinaldorodrigues-vscode.hf.space/v1"
46
+ export OPENAI_API_KEY="local"
47
  export OPENAI_MODEL="qwen-coder"
48
+ export API_TIMEOUT_MS="600000"
49
+ npx openclaude
50
+ EOF
 
 
51
 
52
+ chmod +x abrir_claude
53
+ ./abrir_claude
 
 
 
 
 
 
 
 
 
54
  ```
55
 
56
+ If you create the Hugging Face Secret `API_KEY`, set `OPENAI_API_KEY` to the
57
+ same value. If `API_KEY` is empty, authentication is disabled.
58
 
59
+ ## Tool calling
 
 
 
 
 
 
 
 
60
 
61
+ The selected Qwen3 GGUF contains native `<tools>`, `<tool_call>` and
62
+ `<tool_response>` support.
 
 
 
63
 
64
+ The compatibility layer:
65
+ - passes OpenAI tool schemas to the native Qwen template;
66
+ - parses native Qwen tool blocks and raw tool JSON;
67
+ - returns real OpenAI `message.tool_calls`;
68
+ - never reports required tool JSON as a successful plain-text action;
69
+ - accepts real `role="tool"` responses from OpenClaude;
70
+ - changes repeated `required` to `auto` immediately after a tool result, so
71
+ the agent can finish instead of being forced into a tool loop;
72
+ - supports multiple independent calls when `parallel_tool_calls` allows them.
73
 
74
+ Tool turns requested with `stream=true` are validated fully first and then
75
+ emitted as OpenAI SSE chunks. Normal chat without tools uses real token
76
+ streaming from llama.cpp.
 
77
 
78
+ ## Endpoints
79
 
80
+ - `GET /`
81
+ - `GET /health`
82
+ - `GET /ready`
83
+ - `GET /v1/models`
84
+ - `POST /v1/chat/completions`
85
+ - `GET /docs`
86
 
87
+ `/health` does not load the model. `/ready` returns 503 until the GGUF is
88
+ actually loaded.
89
 
90
+ ## Environment variables
 
 
 
 
91
 
92
+ ```text
93
+ MODEL_REPO=unsloth/Qwen3-4B-Instruct-2507-GGUF
94
+ MODEL_FILE=Qwen3-4B-Instruct-2507-Q4_K_M.gguf
95
+ MODEL_ALIAS=qwen-coder
96
+ MODEL_ALIASES=qwen3-4b,Qwen3-4B-Instruct-2507,unsloth/Qwen3-4B-Instruct-2507-GGUF
97
 
98
+ N_CTX=8192
99
+ MAX_NEW_TOKENS=2048
100
+ N_THREADS=2
101
+ N_THREADS_BATCH=2
102
+ N_BATCH=128
103
+ N_UBATCH=64
104
 
105
+ PRELOAD_MODEL=false
106
+ MODEL_RETRY_COOLDOWN_SECONDS=30
107
+ MAX_REQUEST_BYTES=2000000
108
 
109
+ API_KEY=
110
+ HF_TOKEN=
 
111
  ```
112
 
113
+ If persistent Space storage is attached, you may set `HF_HOME` to a writable
114
+ persistent path (for example `/data/huggingface`) to retain the GGUF cache.
115
+
116
+ ## Validation
117
 
118
  ```bash
119
+ python -m compileall -q app.py settings.py tooling.py tests
120
+ python -m unittest discover -s tests -v
121
  ```
122
 
123
+ After deployment:
124
 
125
  ```bash
126
+ bash smoke_test.sh
127
  ```
 
 
 
 
 
 
 
 
 
 
 
 
VALIDATION.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Vscode Qwen3 CPU fixed package
2
+ Generated: 2026-08-11
3
+
4
+ VALIDATION RESULT
5
+
6
+ Python compileall:
7
+ PASS
8
+
9
+ Unit tests:
10
+ 17 tests executed
11
+ 17 passed
12
+ 0 failed
13
+
14
+ Validated contracts:
15
+ - app.py / settings.py / tooling.py compile successfully.
16
+ - Dockerfile does not force a source build of llama-cpp-python.
17
+ - Dockerfile does not install a compiler/CMake/Ninja toolchain.
18
+ - Dockerfile pins llama-cpp-python 0.3.34 through the official CPU wheel index.
19
+ - requirements.txt contains no Torch, Transformers, Gradio or SentencePiece stack.
20
+ - Qwen native <tool_call> parsing is covered.
21
+ - Raw JSON tool-call fallback is covered.
22
+ - Multiple tool calls are covered.
23
+ - Undeclared tools are rejected.
24
+ - Duplicate calls in one response are deduplicated.
25
+ - tool_choice=required is preserved on the first action turn.
26
+ - required is downgraded to auto after a real tool result to avoid forced loops.
27
+ - simple greetings do not force Bash/tool execution.
28
+ - named/forced tool selection is covered.
29
+ - required API routes are present.
30
+
31
+ Hardware-dependent validation:
32
+ The 2.5 GB GGUF was intentionally not downloaded in the artifact-generation
33
+ environment. The final model load/inference test must run after deploying the
34
+ Docker Space on Hugging Face CPU hardware.
app.py CHANGED
@@ -1,1080 +1,573 @@
1
- """CPU/RAM OpenAI-compatible backend for OpenClaude using Qwen3 GGUF.
2
-
3
- Designed for Hugging Face Spaces CPU Basic (2 vCPU / 16 GB RAM):
4
- - no CUDA / ZeroGPU dependency
5
- - GGUF inference through llama-cpp-python
6
- - Qwen3 native tool-call chat template rendered by Transformers tokenizer
7
- - OpenAI-compatible /v1/chat/completions and SSE tool_call responses
8
- """
9
-
10
  from __future__ import annotations
11
 
12
- import asyncio
13
- import hashlib
14
  import json
15
  import os
16
- import secrets
17
- import shutil
18
  import threading
19
  import time
20
  import traceback
21
  import uuid
22
- from typing import Any
23
 
24
- os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
25
- os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
26
-
27
- import gradio as gr
28
- from fastapi import HTTPException
29
  from fastapi.responses import JSONResponse, StreamingResponse
30
- from huggingface_hub import hf_hub_download
31
- from llama_cpp import Llama
32
- from pydantic import BaseModel, Field, ValidationError
33
  from starlette.concurrency import run_in_threadpool
34
- from starlette.middleware.base import BaseHTTPMiddleware
35
- from starlette.requests import Request
36
- from transformers import AutoTokenizer
37
-
38
- from openai_compat import (
39
- analyze_tool_flow,
40
- compact_forced_tool_messages,
41
- compact_terminal_messages,
42
  indexed_tool_calls,
 
43
  is_simple_greeting,
44
  normalize_tools,
45
- resolve_tool_choice,
46
- select_tools,
47
- tool_choice_instruction,
48
  tool_names,
49
- tool_protocol_instruction,
50
  )
51
- from openclaude_compat import (
52
- TOOL_PROTOCOL_MARKER,
53
- add_system_instruction,
54
- has_tool_protocol,
55
- normalize_openclaude_messages,
56
- )
57
- from tool_calls import extract_tool_calls, has_complete_tool_call, recover_forced_tool_call
58
- from web_search import SearchUnavailable, search_web
59
-
60
-
61
- # CPU model profiles. ``smart`` is the default because Qwen3-4B-Instruct-2507
62
- # is materially stronger at instruction following, coding and tool use while its
63
- # Q4_K_M GGUF (~2.5 GB) still fits comfortably in a 16 GB CPU Space. ``fast``
64
- # retains the previous 1.7B model for users who prefer latency over capability.
65
- MODEL_PROFILE = os.getenv("MODEL_PROFILE", "smart").strip().casefold()
66
- _MODEL_PROFILES = {
67
- "smart": {
68
- "repo": "unsloth/Qwen3-4B-Instruct-2507-GGUF",
69
- "revision": "a06e946bb6b655725eafa393f4a9745d460374c9",
70
- "filename": "Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
71
- "tokenizer": "Qwen/Qwen3-4B-Instruct-2507",
72
- "tokenizer_revision": "cdbee75f17c01a7cc42f958dc650907174af0554",
73
- "display": "Qwen3-4B-Instruct-2507-GGUF-Q4_K_M",
74
- "alias": "qwen3-4b-instruct-2507",
75
- },
76
- "fast": {
77
- "repo": "unsloth/Qwen3-1.7B-GGUF",
78
- "revision": "d7f544eead698dbd1f15126ef60b45a1e1933222",
79
- "filename": "Qwen3-1.7B-Q4_K_M.gguf",
80
- "tokenizer": "Qwen/Qwen3-1.7B",
81
- "tokenizer_revision": "70d244cc86ccca08cf5af4e1e306ecf908b1ad5e",
82
- "display": "Qwen3-1.7B-GGUF-Q4_K_M",
83
- "alias": "qwen3-1.7b",
84
- },
85
- }
86
- if MODEL_PROFILE not in _MODEL_PROFILES:
87
- raise RuntimeError(
88
- f"MODEL_PROFILE must be one of {sorted(_MODEL_PROFILES)}; got {MODEL_PROFILE!r}"
89
- )
90
- _PROFILE = _MODEL_PROFILES[MODEL_PROFILE]
91
- GGUF_REPO = os.getenv("GGUF_REPO", _PROFILE["repo"])
92
- GGUF_FILENAME = os.getenv("GGUF_FILENAME", _PROFILE["filename"])
93
- TOKENIZER_MODEL = os.getenv("TOKENIZER_MODEL", _PROFILE["tokenizer"])
94
- GGUF_REVISION = os.getenv(
95
- "GGUF_REVISION",
96
- _PROFILE["revision"] if GGUF_REPO == _PROFILE["repo"] else "",
97
- ).strip()
98
- TOKENIZER_REVISION = os.getenv(
99
- "TOKENIZER_REVISION",
100
- _PROFILE["tokenizer_revision"]
101
- if TOKENIZER_MODEL == _PROFILE["tokenizer"]
102
- else "",
103
- ).strip()
104
- MODEL = os.getenv("MODEL", os.getenv("MODEL_ID", "qwen-coder"))
105
- MODEL_DISPLAY_NAME = os.getenv("MODEL_DISPLAY_NAME", _PROFILE["display"])
106
-
107
-
108
- def _env_bool(name: str, default: bool) -> bool:
109
- raw = os.getenv(name)
110
- if raw is None:
111
- return default
112
- value = raw.strip().casefold()
113
- if value in {"1", "true", "yes", "on"}:
114
- return True
115
- if value in {"0", "false", "no", "off"}:
116
- return False
117
- raise RuntimeError(f"{name} must be a boolean; got {raw!r}")
118
-
119
- # 32K is an operational limit that is realistic on 16 GB RAM. Larger contexts
120
- # are intentionally not advertised on 2-vCPU CPU Basic because KV cache and
121
- # prompt latency become impractical even when the model supports more tokens.
122
- MAX_SUPPORTED_CONTEXT_TOKENS = 32768
123
- MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", "32768"))
124
- if not 1024 <= MAX_CONTEXT_TOKENS <= MAX_SUPPORTED_CONTEXT_TOKENS:
125
- raise RuntimeError(
126
- f"MAX_CONTEXT_TOKENS must be between 1024 and {MAX_SUPPORTED_CONTEXT_TOKENS}; "
127
- f"got {MAX_CONTEXT_TOKENS}"
128
- )
129
 
130
- MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
131
- MAX_TOOL_CALL_TOKENS = int(os.getenv("MAX_TOOL_CALL_TOKENS", "768"))
132
- MAX_COMPACT_TOOL_TOKENS = int(os.getenv("MAX_COMPACT_TOOL_TOKENS", "384"))
133
- MAX_TERMINAL_SUMMARY_TOKENS = int(os.getenv("MAX_TERMINAL_SUMMARY_TOKENS", "384"))
134
- DEFAULT_TEMPERATURE = float(os.getenv("DEFAULT_TEMPERATURE", "0.0"))
135
- MAX_TEMPERATURE = float(os.getenv("MAX_TEMPERATURE", "0.7"))
136
- TOOL_TEMPERATURE = 0.0
137
- CPU_THREADS = max(1, int(os.getenv("CPU_THREADS", str(min(2, os.cpu_count() or 2)))))
138
- CPU_BATCH_THREADS = max(1, int(os.getenv("CPU_BATCH_THREADS", str(CPU_THREADS))))
139
- N_BATCH = max(64, int(os.getenv("N_BATCH", "1024")))
140
- N_UBATCH = min(N_BATCH, max(32, int(os.getenv("N_UBATCH", "512"))))
141
- FLASH_ATTN = _env_bool("FLASH_ATTN", True)
142
- KV_CACHE_TYPE = os.getenv("KV_CACHE_TYPE", "q8_0").strip().casefold()
143
- _KV_CACHE_TYPES = {"f16": 1, "q8_0": 8}
144
- if KV_CACHE_TYPE not in _KV_CACHE_TYPES:
145
- raise RuntimeError(
146
- f"KV_CACHE_TYPE must be one of {sorted(_KV_CACHE_TYPES)}; got {KV_CACHE_TYPE!r}"
147
- )
148
- KV_CACHE_TYPE_ID = _KV_CACHE_TYPES[KV_CACHE_TYPE]
149
- if not FLASH_ATTN and KV_CACHE_TYPE != "f16":
150
- raise RuntimeError(
151
- "KV_CACHE_TYPE must be f16 when FLASH_ATTN is disabled; llama.cpp "
152
- "requires flash attention for a quantized V cache"
153
- )
154
- MAX_GENERATION_QUEUE = max(1, int(os.getenv("MAX_GENERATION_QUEUE", "4")))
155
- GENERATION_QUEUE_TIMEOUT_SECONDS = max(
156
- 0.0, float(os.getenv("GENERATION_QUEUE_TIMEOUT_SECONDS", "2"))
157
- )
158
- SSE_HEARTBEAT_SECONDS = max(1.0, float(os.getenv("SSE_HEARTBEAT_SECONDS", "10")))
159
- MAX_REQUEST_BYTES = max(1024, int(os.getenv("MAX_REQUEST_BYTES", "4000000")))
160
- API_TOKEN = os.getenv("API_TOKEN", "").strip()
161
- PRELOAD_MODEL = _env_bool("PRELOAD_MODEL", True)
162
- COPY_MODEL_TO_LOCAL = _env_bool("COPY_MODEL_TO_LOCAL", True)
163
- MODEL_RUNTIME_DIR = os.getenv("MODEL_RUNTIME_DIR", "/tmp/qwen-gguf").strip()
164
- MODEL_ALIASES = tuple(
165
- dict.fromkeys(
166
- (
167
- MODEL,
168
- "qwen-coder",
169
- _PROFILE["alias"],
170
- MODEL_DISPLAY_NAME,
171
- GGUF_REPO,
172
- )
173
- )
174
- )
175
 
176
- # Tokenizer is small and used only for Qwen's canonical chat template and token
177
- # accounting. It does not load Transformers model weights or PyTorch.
178
- _tokenizer_kwargs = {"revision": TOKENIZER_REVISION} if TOKENIZER_REVISION else {}
179
- tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_MODEL, **_tokenizer_kwargs)
 
 
180
 
181
- _model: Llama | None = None
 
 
 
182
  _model_path: str | None = None
183
- _MODEL_LOAD_LOCK = threading.Lock()
184
- _GENERATION_LOCK = threading.Lock()
185
- _GENERATION_SLOTS = threading.BoundedSemaphore(MAX_GENERATION_QUEUE)
186
-
187
 
188
- class GenerationBusyError(RuntimeError):
189
- """Raised when the bounded CPU inference queue is already full."""
190
 
191
-
192
- def _local_model_path(cached_path: str) -> str:
193
- """Copy a bucket-cached GGUF to local disk before mmap-based inference."""
194
- if not COPY_MODEL_TO_LOCAL or not MODEL_RUNTIME_DIR:
195
- return cached_path
196
  try:
197
- source_size = os.path.getsize(cached_path)
198
- except OSError:
199
- # Tests and custom hub implementations may supply a virtual path.
200
- return cached_path
201
-
202
- fingerprint = hashlib.sha256(
203
- f"{GGUF_REPO}\0{GGUF_REVISION}\0{GGUF_FILENAME}".encode()
204
- ).hexdigest()[:16]
205
- destination = os.path.join(
206
- MODEL_RUNTIME_DIR,
207
- f"{fingerprint}-{os.path.basename(GGUF_FILENAME)}",
208
- )
209
- try:
210
- os.makedirs(MODEL_RUNTIME_DIR, exist_ok=True)
211
- if os.path.getsize(destination) == source_size:
212
- return destination
213
- except FileNotFoundError:
214
- pass
215
- except OSError:
216
- return cached_path
217
-
218
- temporary = f"{destination}.{os.getpid()}.tmp"
219
- try:
220
- print(f"Copying cached GGUF to local runtime disk: {destination}", flush=True)
221
- shutil.copyfile(cached_path, temporary)
222
- if os.path.getsize(temporary) != source_size:
223
- raise OSError("local GGUF copy has an unexpected size")
224
- os.replace(temporary, destination)
225
- return destination
226
- except OSError:
227
- traceback.print_exc()
228
- try:
229
- os.unlink(temporary)
230
- except FileNotFoundError:
231
- pass
232
- return cached_path
233
 
234
 
235
- def _ensure_model_loaded() -> Llama:
236
- """Download and load the GGUF exactly once per Space process."""
237
- global _model, _model_path
238
- if _model is not None:
239
- return _model
240
 
241
- with _MODEL_LOAD_LOCK:
242
- if _model is not None:
243
- return _model
244
 
245
- print(
246
- f"Loading CPU model {GGUF_REPO}/{GGUF_FILENAME} "
247
- f"ctx={MAX_CONTEXT_TOKENS} threads={CPU_THREADS}",
248
- flush=True,
249
- )
250
- download_kwargs = {"revision": GGUF_REVISION} if GGUF_REVISION else {}
251
- cached_path = hf_hub_download(
252
- repo_id=GGUF_REPO,
253
- filename=GGUF_FILENAME,
254
- **download_kwargs,
255
- )
256
- _model_path = _local_model_path(cached_path)
257
- candidate = Llama(
258
- model_path=_model_path,
259
- n_ctx=MAX_CONTEXT_TOKENS,
260
- n_threads=CPU_THREADS,
261
- n_threads_batch=CPU_BATCH_THREADS,
262
- n_batch=N_BATCH,
263
- n_ubatch=N_UBATCH,
264
- n_gpu_layers=0,
265
- use_mmap=True,
266
- use_mlock=False,
267
- flash_attn=FLASH_ATTN,
268
- type_k=KV_CACHE_TYPE_ID,
269
- type_v=KV_CACHE_TYPE_ID,
270
- no_perf=True,
271
- verbose=False,
272
- )
273
- _model = candidate
274
- print(f"CPU model ready: {_model_path}", flush=True)
275
- return candidate
276
 
277
 
278
- def _bounded_output_tokens(value: float | int) -> int:
279
- try:
280
- requested = int(value)
281
- except (TypeError, ValueError):
282
- requested = MAX_NEW_TOKENS
283
- return max(1, min(requested, MAX_NEW_TOKENS))
284
 
 
 
285
 
286
- def _native_tools(raw_tools: object) -> list[dict[str, Any]]:
287
- return normalize_tools(raw_tools)
 
 
 
 
 
 
 
 
 
288
 
 
 
 
289
 
290
- def _render_prompt(messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> str:
291
- """Render Qwen3's native tool protocol while disabling long think traces."""
292
- template_kwargs: dict[str, Any] = {
293
- "tokenize": False,
294
- "add_generation_prompt": True,
295
- "enable_thinking": False,
296
- }
297
- if tools:
298
- template_kwargs["tools"] = tools
299
- try:
300
- return tokenizer.apply_chat_template(messages, **template_kwargs)
301
- except TypeError:
302
- # Some tokenizer revisions may not expose enable_thinking as a kwarg.
303
- template_kwargs.pop("enable_thinking", None)
304
- return tokenizer.apply_chat_template(messages, **template_kwargs)
305
- except Exception as template_error:
306
- if tools:
307
  raise RuntimeError(
308
- "Qwen chat template failed while tools were enabled; refusing "
309
- "to continue with a tool-less prompt"
310
- ) from template_error
311
- raise
312
-
313
-
314
- def _render_prompt_with_ids(
315
- messages: list[dict[str, Any]], tools: list[dict[str, Any]]
316
- ) -> tuple[str, list[int]]:
317
- prompt = _render_prompt(messages, tools)
318
- ids = tokenizer(prompt, add_special_tokens=False, truncation=False)["input_ids"]
319
- return prompt, ids
320
-
321
-
322
- def _trim_oldest_turn(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
323
- user_indexes = [
324
- index
325
- for index, message in enumerate(messages)
326
- if str(message.get("role", "")).casefold() == "user"
327
- ]
328
- if len(user_indexes) >= 2:
329
- cutoff = user_indexes[1]
330
- return [
331
- message
332
- for index, message in enumerate(messages)
333
- if index >= cutoff or str(message.get("role", "")).casefold() == "system"
334
- ]
335
- if user_indexes and user_indexes[0] > 0:
336
- cutoff = user_indexes[0]
337
- trimmed = [
338
- message
339
- for index, message in enumerate(messages)
340
- if index >= cutoff or str(message.get("role", "")).casefold() == "system"
341
- ]
342
- return trimmed if trimmed != messages else None
343
- return None
344
-
345
-
346
- CONTEXT_TRUNCATION_MARKER = "\n...[older/oversized content truncated to fit context]...\n"
347
-
348
-
349
- def _truncate_text_to_tokens(text: str, target_tokens: int) -> str:
350
- ids = tokenizer(text, add_special_tokens=False, truncation=False)["input_ids"]
351
- target = max(1, int(target_tokens))
352
- if len(ids) <= target:
353
- return text
354
- marker_ids = tokenizer(
355
- CONTEXT_TRUNCATION_MARKER,
356
- add_special_tokens=False,
357
- truncation=False,
358
- )["input_ids"]
359
- payload_budget = max(1, target - len(marker_ids))
360
- head = max(1, payload_budget // 2)
361
- tail = max(0, payload_budget - head)
362
- head_text = tokenizer.decode(ids[:head], skip_special_tokens=False)
363
- tail_text = tokenizer.decode(ids[-tail:], skip_special_tokens=False) if tail else ""
364
- return head_text + CONTEXT_TRUNCATION_MARKER + tail_text
365
-
366
-
367
- def _fit_messages_to_context(
368
- messages: list[dict[str, Any]],
369
- tools: list[dict[str, Any]],
370
- output_tokens: int,
371
- ) -> list[dict[str, Any]]:
372
- """Keep complete Qwen tool schemas intact while fitting the 32K CPU context."""
373
- fitted, _, _ = _fit_messages_to_context_prepared(messages, tools, output_tokens)
374
- return fitted
375
-
376
-
377
- def _fit_messages_to_context_prepared(
378
- messages: list[dict[str, Any]],
379
- tools: list[dict[str, Any]],
380
- output_tokens: int,
381
- ) -> tuple[list[dict[str, Any]], str, list[int]]:
382
- """Fit messages and retain the final render so callers do not redo work."""
383
- input_budget = max(1, MAX_CONTEXT_TOKENS - output_tokens)
384
- fitted = [dict(message) for message in messages]
385
- prompt, prompt_ids = _render_prompt_with_ids(fitted, tools)
386
-
387
- while len(prompt_ids) > input_budget:
388
- trimmed = _trim_oldest_turn(fitted)
389
- if trimmed is None or trimmed == fitted:
390
- break
391
- fitted = trimmed
392
- prompt, prompt_ids = _render_prompt_with_ids(fitted, tools)
393
-
394
- latest_user_index = max(
395
- (
396
- index
397
- for index, message in enumerate(fitted)
398
- if str(message.get("role", "")).casefold() == "user"
399
- ),
400
- default=-1,
401
- )
402
-
403
- for _ in range(max(8, len(fitted) * 4)):
404
- current_length = len(prompt_ids)
405
- if current_length <= input_budget:
406
- return fitted, prompt, prompt_ids
407
- excess = current_length - input_budget
408
- candidates: list[tuple[int, int, int]] = []
409
- for index, message in enumerate(fitted):
410
- content = message.get("content")
411
- if not isinstance(content, str) or not content:
412
- continue
413
- if TOOL_PROTOCOL_MARKER in content:
414
- continue
415
- role = str(message.get("role", "")).casefold()
416
- minimum = 768 if index == latest_user_index else (512 if role in {"system", "tool"} else 256)
417
- token_length = len(
418
- tokenizer(content, add_special_tokens=False, truncation=False)["input_ids"]
419
  )
420
- if token_length > minimum:
421
- candidates.append((token_length, index, minimum))
422
- if not candidates:
423
- break
424
- token_length, index, minimum = max(candidates)
425
- target = max(minimum, token_length - excess - 64)
426
- if target >= token_length:
427
- target = max(minimum, token_length // 2)
428
- original = str(fitted[index]["content"])
429
- shortened = _truncate_text_to_tokens(original, target)
430
- if shortened == original:
431
- break
432
- fitted[index] = {**fitted[index], "content": shortened}
433
- prompt, prompt_ids = _render_prompt_with_ids(fitted, tools)
434
-
435
- if len(prompt_ids) > input_budget:
436
- raise ValueError(
437
- "tool-enabled prompt exceeds the configured CPU context window even "
438
- "after whole-turn and message-content compaction; refusing to slice "
439
- "the Qwen tool schema"
440
- )
441
- return fitted, prompt, prompt_ids
442
-
443
 
444
- def _prepare_prompt(
445
- messages: list[dict[str, Any]],
446
- tools: list[dict[str, Any]],
447
- output_tokens: int,
448
- ) -> tuple[str, int]:
449
- """Compact, render and tokenize once for generation and usage accounting."""
450
- _, prompt, encoded = _fit_messages_to_context_prepared(
451
- messages,
452
- tools,
453
- output_tokens,
454
- )
455
- input_budget = max(1, MAX_CONTEXT_TOKENS - output_tokens)
456
- if len(encoded) > input_budget:
457
- raise ValueError("prompt exceeds CPU context after safe compaction")
458
- return prompt, len(encoded)
459
-
460
-
461
- def _completion_token_count(text: str) -> int:
462
- return len(tokenizer(text, add_special_tokens=False, truncation=False)["input_ids"])
463
 
 
 
 
464
 
465
- def gerar(
466
- messages_json: str,
467
- temperature: float,
468
- max_new_tokens: float,
469
- tools_json: str = "[]",
470
- *,
471
- prepared_prompt: str | None = None,
472
- prepared_prompt_tokens: int | None = None,
473
- ) -> str:
474
- """Generate entirely on CPU/RAM with llama.cpp."""
475
- try:
476
- tools = _native_tools(json.loads(tools_json))
477
- except (TypeError, ValueError, json.JSONDecodeError):
478
- tools = []
479
-
480
- output_tokens = _bounded_output_tokens(max_new_tokens)
481
- if prepared_prompt is None:
482
- messages = json.loads(messages_json)
483
- if not isinstance(messages, list):
484
- raise ValueError("messages_json must contain a JSON list")
485
- prompt, prompt_token_count = _prepare_prompt(messages, tools, output_tokens)
486
- else:
487
- prompt = prepared_prompt
488
- if prepared_prompt_tokens is None:
489
- prompt_token_count = len(
490
- tokenizer(prompt, add_special_tokens=False, truncation=False)["input_ids"]
491
  )
492
- else:
493
- prompt_token_count = max(1, int(prepared_prompt_tokens))
494
- if prompt_token_count > max(1, MAX_CONTEXT_TOKENS - output_tokens):
495
- raise ValueError("prompt exceeds CPU context after safe compaction")
 
496
 
497
- temp = max(0.0, min(float(temperature), MAX_TEMPERATURE))
498
- kwargs: dict[str, Any] = {
499
- "max_tokens": output_tokens,
500
- "temperature": temp,
501
- "stop": ["<|im_end|>", "<|endoftext|>"],
502
- "echo": False,
503
- }
504
- if temp > 0:
505
- kwargs.update({"top_p": 0.8, "top_k": 20, "repeat_penalty": 1.05})
506
-
507
- # A single Llama instance/context is not safe for overlapping completion
508
- # calls. Every entry point (OpenAI middleware, Gradio, tests, future
509
- # helpers) must serialize at this lowest level. Keeping the lock here is
510
- # intentional: a route-level lock can be bypassed by another caller and
511
- # corrupt llama.cpp's shared KV/evaluation state, which may abort the whole
512
- # process inside GGML rather than raise a Python exception.
513
- queued_at = time.monotonic()
514
- admitted = _GENERATION_SLOTS.acquire(timeout=GENERATION_QUEUE_TIMEOUT_SECONDS)
515
- if not admitted:
516
- raise GenerationBusyError(
517
- "CPU inference queue is full; retry after the current requests finish"
518
- )
519
- try:
520
- with _GENERATION_LOCK:
521
- llm = _ensure_model_loaded()
522
- queue_wait = time.monotonic() - queued_at
523
- print(
524
- f"CPU generation: prompt_tokens={prompt_token_count} "
525
- f"max_new_tokens={output_tokens} threads={CPU_THREADS} "
526
- f"tools={len(tools)} queue_wait={queue_wait:.2f}s",
527
- flush=True,
528
  )
529
- started = time.monotonic()
530
- response = llm(prompt, **kwargs)
531
- text = str(response["choices"][0].get("text") or "").strip()
532
- elapsed = time.monotonic() - started
533
  print(
534
- f"CPU generation completed: output_tokens={_completion_token_count(text)} "
535
- f"elapsed={elapsed:.2f}s",
536
  flush=True,
537
  )
538
- return text
539
- finally:
540
- _GENERATION_SLOTS.release()
 
 
 
 
 
541
 
542
 
543
  class ChatCompletionRequest(BaseModel):
544
- model: str = MODEL
545
- messages: list[dict[str, Any]] = Field(min_length=1)
546
- temperature: float = Field(default=DEFAULT_TEMPERATURE, ge=0.0)
547
- max_tokens: int | None = Field(default=None, ge=1)
548
- max_completion_tokens: int | None = Field(default=None, ge=1)
 
549
  stream: bool = False
550
  tools: list[dict[str, Any]] | None = None
551
  tool_choice: Any = None
552
  parallel_tool_calls: bool | None = None
553
- stream_options: dict[str, Any] | None = None
 
 
 
 
 
554
 
555
 
556
- def _simple_greeting_payload(request: ChatCompletionRequest) -> dict[str, Any] | None:
557
- requested_mode = (
558
- request.tool_choice.casefold()
559
- if isinstance(request.tool_choice, str)
560
- else None
 
 
 
 
 
 
 
 
 
 
 
561
  )
562
- # The deterministic greeting is valid only when the caller left tool use
563
- # optional (or explicitly disabled it). ``required`` and forced-function
564
- # choices are wire-level constraints and must reach the normal tool router.
565
- if request.tool_choice is not None and requested_mode not in {"auto", "none"}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  return None
567
  if not is_simple_greeting(request.messages):
568
  return None
569
- text = "Olá! Como posso ajudar você hoje?"
570
- prompt_tokens = _completion_token_count(json.dumps(request.messages, ensure_ascii=False))
571
- completion_tokens = _completion_token_count(text)
572
  return {
573
- "id": f"chatcmpl-{uuid.uuid4().hex}",
574
  "object": "chat.completion",
575
  "created": int(time.time()),
576
- "model": MODEL,
577
  "choices": [
578
  {
579
  "index": 0,
580
- "message": {"role": "assistant", "content": text},
 
 
 
581
  "finish_reason": "stop",
 
582
  }
583
  ],
584
  "usage": {
585
- "prompt_tokens": prompt_tokens,
586
- "completion_tokens": completion_tokens,
587
- "total_tokens": prompt_tokens + completion_tokens,
588
  },
589
  }
590
 
591
 
592
  def _completion_payload(request: ChatCompletionRequest) -> dict[str, Any]:
593
- if request.model not in MODEL_ALIASES:
594
- raise HTTPException(status_code=404, detail=f"Model not available: {request.model}")
595
-
596
- greeting = _simple_greeting_payload(request)
597
- if greeting is not None:
598
- return greeting
599
-
600
- already_adapted = has_tool_protocol(request.messages)
601
- flow_state = analyze_tool_flow(request.messages, request.tools or [])
602
- requested_mode = request.tool_choice.casefold() if isinstance(request.tool_choice, str) else None
603
- state_controls_choice = request.tool_choice is None or requested_mode in {"auto", "required"}
604
- effective_choice = resolve_tool_choice(request.tool_choice, flow_state)
605
  try:
606
- effective_tools, tool_mode = select_tools(request.tools or [], effective_choice)
 
 
 
 
 
607
  except ValueError as error:
608
  raise HTTPException(status_code=400, detail=str(error)) from error
609
 
610
- allow_parallel = request.parallel_tool_calls is True
611
- instructions = [
612
- instruction
613
- for instruction in (
614
- (
615
- tool_protocol_instruction(
616
- effective_tools,
617
- parallel_tool_calls=allow_parallel,
618
- )
619
- if effective_tools and not has_tool_protocol(request.messages)
620
- else None
621
- ),
622
- tool_choice_instruction(tool_mode, effective_tools),
623
- (
624
- flow_state.instruction
625
- if (
626
- state_controls_choice
627
- and not already_adapted
628
- and not (
629
- requested_mode == "required"
630
- and flow_state.can_finalize
631
- and not flow_state.requires_tool
632
- )
633
- )
634
- else None
635
- ),
636
- )
637
- if instruction
638
- ]
639
- instruction = "\n\n".join(instructions) if instructions else None
640
-
641
- if request.max_completion_tokens is not None:
642
- max_tokens = request.max_completion_tokens
643
- elif request.max_tokens is not None:
644
- max_tokens = request.max_tokens
645
- else:
646
- max_tokens = MAX_NEW_TOKENS
647
- if effective_tools:
648
- max_tokens = min(max_tokens, MAX_TOOL_CALL_TOKENS)
649
 
650
- temperature = min(max(float(request.temperature), 0.0), MAX_TEMPERATURE)
651
- if tool_mode in {"required", "forced"}:
652
- temperature = TOOL_TEMPERATURE
 
 
653
 
654
  try:
655
- if flow_state.terminal and flow_state.can_finalize and not flow_state.requires_tool:
656
- # The tool work is already proven complete. Re-sending OpenClaude's
657
- # multi-thousand-token system prompt and tool manuals on 2 vCPU makes
658
- # a trivial final summary take minutes and can hit the client hard
659
- # runtime. Use an evidence-only finalization prompt instead.
660
- prompt_messages = compact_terminal_messages(request.messages)
661
- max_tokens = min(max_tokens, MAX_TERMINAL_SUMMARY_TOKENS)
662
- elif (
663
- flow_state.compact_prompt
664
- and flow_state.requires_tool
665
- and flow_state.forced_tool
666
- and len(effective_tools) == 1
667
- ):
668
- # The router already chose the exact function. Avoid re-prefilling
669
- # OpenClaude's full 7-11K-token manual merely to produce arguments
670
- # for one deterministic tool call. The selected tool schema remains
671
- # in Qwen's native template.
672
- compact_instruction = "\n\n".join(
673
- part
674
- for part in (
675
- flow_state.instruction,
676
- tool_choice_instruction(tool_mode, effective_tools),
677
- )
678
- if part
679
- )
680
- prompt_messages = compact_forced_tool_messages(
681
- request.messages,
682
- compact_instruction or instruction,
683
- )
684
- max_tokens = min(max_tokens, MAX_COMPACT_TOOL_TOKENS)
685
- else:
686
- normalized_messages = (
687
- [dict(message) for message in request.messages]
688
- if already_adapted
689
- else normalize_openclaude_messages(request.messages)
690
- )
691
- prompt_messages = add_system_instruction(normalized_messages, instruction)
692
  except ValueError as error:
693
- raise HTTPException(status_code=400, detail=str(error)) from error
 
 
694
 
695
- bounded_max_tokens = _bounded_output_tokens(max_tokens)
696
- try:
697
- prepared_prompt, prompt_tokens = _prepare_prompt(
698
- prompt_messages,
699
- effective_tools,
700
- bounded_max_tokens,
701
- )
702
- except ValueError as error:
703
- raise HTTPException(status_code=413, detail=str(error)) from error
704
-
705
- print(
706
- "Tool routing: "
707
- f"requested={request.tool_choice!r} mode={tool_mode} "
708
- f"state_required={flow_state.requires_tool} terminal={flow_state.terminal} "
709
- f"phase={flow_state.phase} steps={flow_state.step_count} "
710
- f"compact={flow_state.compact_prompt} forced={flow_state.forced_tool!r} "
711
- f"effective_tools={[tool['function']['name'] for tool in effective_tools]}",
712
- flush=True,
713
- )
714
 
715
- try:
716
- text = gerar(
717
- json.dumps(prompt_messages, ensure_ascii=False),
718
- temperature,
719
- bounded_max_tokens,
720
- json.dumps(effective_tools, ensure_ascii=False),
721
- prepared_prompt=prepared_prompt,
722
- prepared_prompt_tokens=prompt_tokens,
723
- )
724
- except GenerationBusyError as error:
725
- raise HTTPException(status_code=429, detail=str(error)) from error
726
- completion_tokens = _completion_token_count(text)
727
-
728
- if effective_tools:
729
- tool_calls, content = extract_tool_calls(text, tool_names(effective_tools))
730
- if not tool_calls and tool_mode in {"forced", "required"} and len(effective_tools) == 1:
731
- recovered = recover_forced_tool_call(text, effective_tools[0]["function"]["name"])
732
- if recovered is not None:
733
- tool_calls, content = [recovered], ""
734
- if not allow_parallel:
735
- tool_calls = tool_calls[:1]
736
- else:
737
- tool_calls, content = [], text
738
 
739
- if effective_tools and not tool_calls and has_complete_tool_call(text):
740
- raise HTTPException(
741
- status_code=502,
742
- detail=(
743
- "Model produced a complete but invalid or unadvertised tool call; "
744
- "refusing to expose it as plain text to the tool executor."
745
- ),
746
- )
747
 
748
- message: dict[str, Any] = {"role": "assistant", "content": content or None}
749
- if tool_mode in {"required", "forced"} and not tool_calls:
750
- detail = (
751
- "CPU model failed to produce a valid required tool call. "
752
- "No plain-text success response was returned because OpenClaude requested tool execution."
753
- )
754
- if completion_tokens >= bounded_max_tokens:
755
- detail += " Generation reached the output-token limit."
756
- raise HTTPException(status_code=502, detail=detail)
757
 
758
- finish_reason = "stop"
759
- if tool_calls:
760
- message["tool_calls"] = tool_calls
 
 
 
 
 
 
 
 
 
 
 
761
  finish_reason = "tool_calls"
762
- elif completion_tokens >= bounded_max_tokens:
763
- finish_reason = "length"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
 
765
  return {
766
- "id": f"chatcmpl-{uuid.uuid4().hex}",
767
  "object": "chat.completion",
768
- "created": int(time.time()),
769
- "model": MODEL,
770
- "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
771
- "usage": {
772
- "prompt_tokens": prompt_tokens,
773
- "completion_tokens": completion_tokens,
774
- "total_tokens": prompt_tokens + completion_tokens,
775
- },
776
- }
777
-
778
-
779
- def health() -> dict[str, Any]:
780
- return {
781
- "status": "ok",
782
- "runtime": "cpu-llama.cpp",
783
- "model": MODEL,
784
- "model_display_name": MODEL_DISPLAY_NAME,
785
- "model_profile": MODEL_PROFILE,
786
- "agent_controller": "autonomous-inspect-act-verify",
787
- "generation_serialized": True,
788
- "gguf_repo": GGUF_REPO,
789
- "gguf_revision": GGUF_REVISION or None,
790
- "gguf_filename": GGUF_FILENAME,
791
- "model_loaded": _model is not None,
792
- "context_length": MAX_CONTEXT_TOKENS,
793
- "cpu_threads": CPU_THREADS,
794
- "cpu_batch_threads": CPU_BATCH_THREADS,
795
- "n_batch": N_BATCH,
796
- "n_ubatch": N_UBATCH,
797
- "flash_attention": FLASH_ATTN,
798
- "kv_cache_type": KV_CACHE_TYPE,
799
- "max_generation_queue": MAX_GENERATION_QUEUE,
800
- "preload_model": PRELOAD_MODEL,
801
- "copy_model_to_local": COPY_MODEL_TO_LOCAL,
802
- "zero_gpu": False,
803
- }
804
-
805
-
806
- def readiness() -> tuple[dict[str, Any], int]:
807
- if _model is None:
808
- return {"status": "loading", "model_loaded": False}, 503
809
- return {"status": "ready", "model_loaded": True}, 200
810
-
811
-
812
- def models() -> dict[str, Any]:
813
- return {
814
- "object": "list",
815
- "data": [
816
  {
817
- "id": model_id,
818
- "object": "model",
819
- "owned_by": "Erinaldorodrigues",
820
- "context_length": MAX_CONTEXT_TOKENS,
821
- "max_input_tokens": MAX_CONTEXT_TOKENS,
822
- "max_output_tokens": MAX_NEW_TOKENS,
823
- "runtime": "cpu-llama.cpp",
824
- "model_profile": MODEL_PROFILE,
825
- "agent_controller": "autonomous-inspect-act-verify",
826
  }
827
- for model_id in MODEL_ALIASES
828
  ],
 
829
  }
830
 
831
 
832
- def _completion_sse_events(
833
- request: ChatCompletionRequest,
834
- ):
835
- """Start inference in a worker and keep the SSE connection visibly alive."""
836
-
837
- async def events():
838
- # Tool calls must be parsed and validated before they can be exposed, so
839
- # generation remains buffered. SSE comments prevent proxies and clients
840
- # from treating a long CPU prefill as a dead connection.
841
- yield ": stream-open\n\n"
842
- completion_task = asyncio.create_task(
843
- run_in_threadpool(_completion_payload, request)
844
- )
845
- while True:
846
- try:
847
- completion = await asyncio.wait_for(
848
- asyncio.shield(completion_task),
849
- timeout=SSE_HEARTBEAT_SECONDS,
850
- )
851
- break
852
- except asyncio.TimeoutError:
853
- yield ": keep-alive\n\n"
854
- except HTTPException as error:
855
- payload = {
856
- "error": {
857
- "message": str(error.detail),
858
- "type": "server_error",
859
- "code": error.status_code,
860
- }
861
- }
862
- yield f"data: {json.dumps(payload)}\n\n"
863
- yield "data: [DONE]\n\n"
864
- return
865
- except Exception:
866
- traceback.print_exc()
867
- payload = {
868
- "error": {
869
- "message": "internal CPU Space error",
870
- "type": "server_error",
871
- "code": 500,
872
- }
873
- }
874
- yield f"data: {json.dumps(payload)}\n\n"
875
- yield "data: [DONE]\n\n"
876
- return
877
 
878
- choice = completion["choices"][0]
879
- chunk_id = completion["id"]
880
- first = {
881
  "id": chunk_id,
882
  "object": "chat.completion.chunk",
883
- "created": completion["created"],
884
- "model": MODEL,
885
  "choices": [
886
- {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}
887
- ],
888
- "usage": None,
889
- }
890
- yield f"data: {json.dumps(first)}\n\n"
891
- delta: dict[str, Any] = {}
892
- if choice["message"].get("content"):
893
- delta["content"] = choice["message"]["content"]
894
- if choice["message"].get("tool_calls"):
895
- delta["tool_calls"] = indexed_tool_calls(choice["message"]["tool_calls"])
896
- body = {**first, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}
897
- yield f"data: {json.dumps(body)}\n\n"
898
- final = {
899
- **first,
900
- "choices": [
901
- {"index": 0, "delta": {}, "finish_reason": choice["finish_reason"]}
902
  ],
903
  }
904
- yield f"data: {json.dumps(final)}\n\n"
905
- if request.stream_options and request.stream_options.get("include_usage") is True:
906
- usage_chunk = {
907
- "id": chunk_id,
908
- "object": "chat.completion.chunk",
909
- "created": completion["created"],
910
- "model": MODEL,
911
- "choices": [],
912
- "usage": completion["usage"],
913
- }
914
- yield f"data: {json.dumps(usage_chunk)}\n\n"
915
- yield "data: [DONE]\n\n"
916
-
917
- return events()
918
-
919
 
920
- def chat_completions(request: ChatCompletionRequest):
921
- if request.stream:
922
- return StreamingResponse(
923
- _completion_sse_events(request),
924
- media_type="text/event-stream",
925
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
926
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
 
928
- completion = _completion_payload(request)
929
- return JSONResponse(content=completion)
930
-
 
 
 
 
 
 
 
931
 
932
- async def _chat_completions_in_thread(parsed_request: ChatCompletionRequest):
933
- if parsed_request.stream:
934
- return chat_completions(parsed_request)
935
- return await run_in_threadpool(chat_completions, parsed_request)
936
 
 
 
 
 
 
 
 
 
 
 
 
 
937
 
938
- def ui_status() -> str:
939
- """Lightweight Space UI that never invokes the shared Llama context."""
940
- return json.dumps(health(), ensure_ascii=False, indent=2)
 
 
 
 
 
941
 
942
 
943
- demo = gr.Interface(
944
- fn=ui_status,
945
- inputs=[],
946
- outputs="text",
947
- title="Qwen3 GGUF CPU/RAM OpenAI-compatible Backend",
948
- description=(
949
- "CPU-only llama.cpp backend for OpenClaude. Inference is intentionally "
950
- "served only through /v1/chat/completions so every model call passes "
951
- "through the single-context serialization guard."
952
- ),
953
- )
 
 
 
 
954
 
955
 
956
- class OpenAIRouteMiddleware(BaseHTTPMiddleware):
957
- async def dispatch(self, request: Request, call_next):
958
- supplied_request_id = request.headers.get("x-request-id", "").strip()
959
- request_id = (
960
- supplied_request_id
961
- if supplied_request_id and len(supplied_request_id) <= 128
962
- else uuid.uuid4().hex
963
- )
964
- response = await self._dispatch_routes(request, call_next)
965
- response.headers["X-Request-ID"] = request_id
966
- return response
967
-
968
- async def _dispatch_routes(self, request: Request, call_next):
969
- path = request.url.path.rstrip("/") or "/"
970
- if path == "/health" and request.method == "GET":
971
- return JSONResponse(health())
972
- if path == "/ready" and request.method == "GET":
973
- payload, status_code = readiness()
974
- return JSONResponse(payload, status_code=status_code)
975
- if API_TOKEN and (path.startswith("/v1/") or path == "/web-search"):
976
- authorization = request.headers.get("authorization", "")
977
- scheme, _, supplied_token = authorization.partition(" ")
978
- authorized = (
979
- scheme.casefold() == "bearer"
980
- and bool(supplied_token)
981
- and secrets.compare_digest(supplied_token, API_TOKEN)
982
- )
983
- if not authorized:
984
- return JSONResponse(
985
- status_code=401,
986
- content={"error": {"message": "unauthorized"}},
987
- headers={"WWW-Authenticate": "Bearer"},
988
- )
989
- if path == "/web-search" and request.method == "GET":
990
- query = request.query_params.get("q", "").strip()
991
- if not query or len(query) > 500:
992
- return JSONResponse(status_code=400, content={"error": "invalid query"})
993
- try:
994
- return JSONResponse(await run_in_threadpool(search_web, query))
995
- except SearchUnavailable as error:
996
- return JSONResponse(
997
- status_code=503,
998
- content={"error": {"message": str(error) or "search unavailable"}},
999
- )
1000
- except Exception:
1001
- traceback.print_exc()
1002
- return JSONResponse(
1003
- status_code=500,
1004
- content={"error": {"message": "internal web-search error"}},
1005
- )
1006
- if path == "/v1/models" and request.method == "GET":
1007
- return JSONResponse(models())
1008
- if path == "/v1/chat/completions" and request.method == "POST":
1009
- try:
1010
- content_length = request.headers.get("content-length")
1011
- if content_length is not None and int(content_length) > MAX_REQUEST_BYTES:
1012
- return JSONResponse(
1013
- status_code=413,
1014
- content={"error": {"message": "request body too large"}},
1015
- )
1016
- raw_body = await request.body()
1017
- if len(raw_body) > MAX_REQUEST_BYTES:
1018
- return JSONResponse(
1019
- status_code=413,
1020
- content={"error": {"message": "request body too large"}},
1021
- )
1022
- raw_request = json.loads(raw_body)
1023
- parsed_request = ChatCompletionRequest(**raw_request)
1024
- except (json.JSONDecodeError, UnicodeDecodeError, ValidationError, TypeError, ValueError) as error:
1025
- return JSONResponse(
1026
- status_code=400, content={"error": {"message": str(error)}}
1027
- )
1028
- try:
1029
- return await _chat_completions_in_thread(parsed_request)
1030
- except HTTPException as error:
1031
- return JSONResponse(
1032
- status_code=error.status_code,
1033
- content={"error": {"message": error.detail}},
1034
- )
1035
- except Exception:
1036
- traceback.print_exc()
1037
- return JSONResponse(
1038
- status_code=500,
1039
- content={"error": {"message": "internal CPU Space error"}},
1040
- )
1041
- return await call_next(request)
1042
 
1043
 
1044
- import gradio.routes as _groutes
 
 
 
 
 
 
 
 
 
 
 
1045
 
1046
- _original_create_app = _groutes.App.create_app
1047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
 
1049
- def _create_app_with_openai_routes(*args, **kwargs):
1050
- created = _original_create_app(*args, **kwargs)
1051
- created.add_middleware(OpenAIRouteMiddleware)
1052
- return created
1053
 
 
 
 
 
1054
 
1055
- _groutes.App.create_app = staticmethod(_create_app_with_openai_routes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
 
1058
- def _preload_model() -> None:
1059
  try:
1060
- _ensure_model_loaded()
1061
- except Exception:
1062
- print("Background model preload failed; readiness remains unavailable.", flush=True)
 
 
1063
  traceback.print_exc()
 
 
 
1064
 
1065
 
1066
- def main() -> None:
1067
- if PRELOAD_MODEL:
1068
- threading.Thread(
1069
- target=_preload_model,
1070
- name="model-preload",
1071
- daemon=True,
1072
- ).start()
1073
- demo.queue(default_concurrency_limit=1, max_size=8).launch(
1074
- show_error=True,
1075
- ssr_mode=False,
1076
- )
1077
-
1078
-
1079
- if __name__ == "__main__":
1080
- main()
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import importlib.metadata
 
4
  import json
5
  import os
 
 
6
  import threading
7
  import time
8
  import traceback
9
  import uuid
10
+ from typing import Any, Iterator
11
 
12
+ from fastapi import FastAPI, HTTPException, Request
 
 
 
 
13
  from fastapi.responses import JSONResponse, StreamingResponse
14
+ from pydantic import BaseModel
 
 
15
  from starlette.concurrency import run_in_threadpool
16
+
17
+ from settings import Settings
18
+ from tooling import (
19
+ build_tool_plan,
20
+ extract_tool_calls,
 
 
 
21
  indexed_tool_calls,
22
+ inject_system_instruction,
23
  is_simple_greeting,
24
  normalize_tools,
 
 
 
25
  tool_names,
 
26
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ SETTINGS = Settings.from_env()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ app = FastAPI(
31
+ title="Qwen3 CPU OpenAI API",
32
+ version="4.0.0",
33
+ docs_url="/docs",
34
+ redoc_url=None,
35
+ )
36
 
37
+ _model: Any = None
38
+ _model_state = "cold"
39
+ _model_error: str | None = None
40
+ _model_last_error_at = 0.0
41
  _model_path: str | None = None
42
+ _model_load_lock = threading.Lock()
43
+ _inference_lock = threading.Lock()
 
 
44
 
 
 
45
 
46
+ def _version(distribution: str) -> str:
 
 
 
 
47
  try:
48
+ return importlib.metadata.version(distribution)
49
+ except importlib.metadata.PackageNotFoundError:
50
+ return "missing"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
+ def _short_error(error: BaseException) -> str:
54
+ message = f"{type(error).__name__}: {error}".replace("\n", " ").strip()
55
+ return message[:500]
 
 
56
 
 
 
 
57
 
58
+ def _model_loaded() -> bool:
59
+ return _model is not None and _model_state == "ready"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
 
62
+ def _ensure_model_loaded() -> Any:
63
+ global _model, _model_state, _model_error
64
+ global _model_last_error_at, _model_path
 
 
 
65
 
66
+ if _model_loaded():
67
+ return _model
68
 
69
+ now = time.monotonic()
70
+ if (
71
+ _model is None
72
+ and _model_state == "error"
73
+ and SETTINGS.model_retry_cooldown_seconds > 0
74
+ and now - _model_last_error_at < SETTINGS.model_retry_cooldown_seconds
75
+ ):
76
+ raise RuntimeError(
77
+ "Model load is in cooldown after the previous failure: "
78
+ + (_model_error or "unknown error")
79
+ )
80
 
81
+ with _model_load_lock:
82
+ if _model_loaded():
83
+ return _model
84
 
85
+ now = time.monotonic()
86
+ if (
87
+ _model is None
88
+ and _model_state == "error"
89
+ and SETTINGS.model_retry_cooldown_seconds > 0
90
+ and now - _model_last_error_at < SETTINGS.model_retry_cooldown_seconds
91
+ ):
 
 
 
 
 
 
 
 
 
 
92
  raise RuntimeError(
93
+ "Model load is in cooldown after the previous failure: "
94
+ + (_model_error or "unknown error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ _model_state = "loading"
98
+ _model_error = None
99
+ started = time.monotonic()
100
+ print(
101
+ f"Loading {SETTINGS.model_repo}/{SETTINGS.model_file} on CPU "
102
+ f"(ctx={SETTINGS.n_ctx}, threads={SETTINGS.n_threads})...",
103
+ flush=True,
104
+ )
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ try:
107
+ from huggingface_hub import hf_hub_download
108
+ from llama_cpp import Llama
109
 
110
+ downloaded = hf_hub_download(
111
+ repo_id=SETTINGS.model_repo,
112
+ filename=SETTINGS.model_file,
113
+ token=os.getenv("HF_TOKEN") or None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  )
115
+ size = os.path.getsize(downloaded)
116
+ if size < SETTINGS.model_min_bytes:
117
+ raise RuntimeError(
118
+ f"Downloaded GGUF is unexpectedly small: {size} bytes"
119
+ )
120
 
121
+ model = Llama(
122
+ model_path=downloaded,
123
+ n_ctx=SETTINGS.n_ctx,
124
+ n_batch=min(SETTINGS.n_batch, SETTINGS.n_ctx),
125
+ n_ubatch=min(SETTINGS.n_ubatch, SETTINGS.n_batch),
126
+ n_threads=SETTINGS.n_threads,
127
+ n_threads_batch=SETTINGS.n_threads_batch,
128
+ n_gpu_layers=0,
129
+ use_mmap=True,
130
+ use_mlock=False,
131
+ verbose=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  )
133
+
134
+ _model = model
135
+ _model_path = downloaded
136
+ _model_state = "ready"
137
  print(
138
+ f"Model ready on CPU in {time.monotonic() - started:.1f}s; "
139
+ f"file={downloaded}",
140
  flush=True,
141
  )
142
+ return model
143
+ except Exception as error:
144
+ _model = None
145
+ _model_state = "error"
146
+ _model_error = _short_error(error)
147
+ _model_last_error_at = time.monotonic()
148
+ traceback.print_exc()
149
+ raise
150
 
151
 
152
  class ChatCompletionRequest(BaseModel):
153
+ model: str = SETTINGS.model_alias
154
+ messages: list[dict[str, Any]]
155
+ temperature: float = 0.0
156
+ top_p: float = 0.95
157
+ max_tokens: int | None = None
158
+ max_completion_tokens: int | None = None
159
  stream: bool = False
160
  tools: list[dict[str, Any]] | None = None
161
  tool_choice: Any = None
162
  parallel_tool_calls: bool | None = None
163
+ stop: str | list[str] | None = None
164
+ seed: int | None = None
165
+ presence_penalty: float = 0.0
166
+ frequency_penalty: float = 0.0
167
+ response_format: dict[str, Any] | None = None
168
+ n: int = 1
169
 
170
 
171
+ def _validate_request(request: ChatCompletionRequest) -> None:
172
+ if request.model not in SETTINGS.model_aliases:
173
+ raise HTTPException(
174
+ status_code=404, detail=f"Model not available: {request.model}"
175
+ )
176
+ if not request.messages:
177
+ raise HTTPException(status_code=400, detail="messages must not be empty")
178
+ if request.n != 1:
179
+ raise HTTPException(status_code=400, detail="Only n=1 is supported")
180
+
181
+
182
+ def _bounded_max_tokens(request: ChatCompletionRequest) -> int:
183
+ raw = (
184
+ request.max_completion_tokens
185
+ if request.max_completion_tokens is not None
186
+ else request.max_tokens
187
  )
188
+ if raw is None:
189
+ raw = SETTINGS.max_new_tokens
190
+ try:
191
+ value = int(raw)
192
+ except (TypeError, ValueError) as exc:
193
+ raise HTTPException(status_code=400, detail="Invalid max_tokens") from exc
194
+ return max(1, min(value, SETTINGS.max_new_tokens))
195
+
196
+
197
+ def _llama_kwargs(
198
+ request: ChatCompletionRequest,
199
+ messages: list[dict[str, Any]],
200
+ tools: list[dict[str, Any]],
201
+ *,
202
+ stream: bool,
203
+ ) -> dict[str, Any]:
204
+ temperature = max(0.0, min(float(request.temperature), 2.0))
205
+ if tools:
206
+ temperature = 0.0
207
+
208
+ kwargs: dict[str, Any] = {
209
+ "messages": messages,
210
+ "temperature": temperature,
211
+ "top_p": max(0.01, min(float(request.top_p), 1.0)),
212
+ "max_tokens": _bounded_max_tokens(request),
213
+ "stream": stream,
214
+ "model": SETTINGS.model_alias,
215
+ "presence_penalty": max(
216
+ -2.0, min(float(request.presence_penalty), 2.0)
217
+ ),
218
+ "frequency_penalty": max(
219
+ -2.0, min(float(request.frequency_penalty), 2.0)
220
+ ),
221
+ }
222
+ if request.stop is not None:
223
+ kwargs["stop"] = request.stop
224
+ if request.seed is not None:
225
+ kwargs["seed"] = int(request.seed)
226
+ if request.response_format is not None and not tools:
227
+ kwargs["response_format"] = request.response_format
228
+ if tools:
229
+ # Qwen3's GGUF embeds the tool Jinja template. The compatibility
230
+ # layer below validates/parses the resulting native tool blocks.
231
+ kwargs["tools"] = tools
232
+ kwargs["tool_choice"] = "auto"
233
+ return kwargs
234
+
235
+
236
+ def _fast_greeting(
237
+ request: ChatCompletionRequest, tool_mode: str
238
+ ) -> dict[str, Any] | None:
239
+ if tool_mode not in {"none", "auto"}:
240
  return None
241
  if not is_simple_greeting(request.messages):
242
  return None
 
 
 
243
  return {
244
+ "id": "chatcmpl-" + uuid.uuid4().hex,
245
  "object": "chat.completion",
246
  "created": int(time.time()),
247
+ "model": SETTINGS.model_alias,
248
  "choices": [
249
  {
250
  "index": 0,
251
+ "message": {
252
+ "role": "assistant",
253
+ "content": "Olá! Como posso ajudar você hoje?",
254
+ },
255
  "finish_reason": "stop",
256
+ "logprobs": None,
257
  }
258
  ],
259
  "usage": {
260
+ "prompt_tokens": 0,
261
+ "completion_tokens": 0,
262
+ "total_tokens": 0,
263
  },
264
  }
265
 
266
 
267
  def _completion_payload(request: ChatCompletionRequest) -> dict[str, Any]:
268
+ _validate_request(request)
269
+ tools = normalize_tools(request.tools or [])
270
+
 
 
 
 
 
 
 
 
 
271
  try:
272
+ plan = build_tool_plan(
273
+ request.messages,
274
+ tools,
275
+ request.tool_choice,
276
+ request.parallel_tool_calls,
277
+ )
278
  except ValueError as error:
279
  raise HTTPException(status_code=400, detail=str(error)) from error
280
 
281
+ fast = _fast_greeting(request, plan.mode)
282
+ if fast is not None:
283
+ return fast
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ messages = inject_system_instruction(request.messages, plan.instruction)
286
+ model = _ensure_model_loaded()
287
+ kwargs = _llama_kwargs(
288
+ request, messages, plan.tools, stream=False
289
+ )
290
 
291
  try:
292
+ with _inference_lock:
293
+ raw = model.create_chat_completion(**kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  except ValueError as error:
295
+ message = str(error)
296
+ status = 413 if "context" in message.casefold() else 400
297
+ raise HTTPException(status_code=status, detail=message) from error
298
 
299
+ if not isinstance(raw, dict):
300
+ raise RuntimeError("llama-cpp-python returned an invalid response")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
+ choices = raw.get("choices")
303
+ if not isinstance(choices, list) or not choices:
304
+ raise RuntimeError("llama-cpp-python returned no choices")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
+ choice = choices[0]
307
+ message = choice.get("message")
308
+ if not isinstance(message, dict):
309
+ message = {"role": "assistant", "content": ""}
 
 
 
 
310
 
311
+ content = message.get("content")
312
+ content_text = content if isinstance(content, str) else ""
 
 
 
 
 
 
 
313
 
314
+ calls = extract_tool_calls(
315
+ content_text,
316
+ tool_names(plan.tools),
317
+ message.get("tool_calls"),
318
+ )
319
+ if request.parallel_tool_calls is False:
320
+ calls = calls[:1]
321
+
322
+ if calls:
323
+ output_message: dict[str, Any] = {
324
+ "role": "assistant",
325
+ "content": None,
326
+ "tool_calls": calls,
327
+ }
328
  finish_reason = "tool_calls"
329
+ else:
330
+ if plan.mode in {"required", "forced"}:
331
+ raise HTTPException(
332
+ status_code=502,
333
+ detail=(
334
+ "Model failed to emit a structured tool call while "
335
+ f"tool_choice was {plan.mode}."
336
+ ),
337
+ )
338
+ output_message = {
339
+ "role": "assistant",
340
+ "content": content_text,
341
+ }
342
+ finish_reason = choice.get("finish_reason") or "stop"
343
+
344
+ usage = raw.get("usage")
345
+ if not isinstance(usage, dict):
346
+ usage = {
347
+ "prompt_tokens": 0,
348
+ "completion_tokens": 0,
349
+ "total_tokens": 0,
350
+ }
351
 
352
  return {
353
+ "id": raw.get("id") or ("chatcmpl-" + uuid.uuid4().hex),
354
  "object": "chat.completion",
355
+ "created": int(raw.get("created") or time.time()),
356
+ "model": SETTINGS.model_alias,
357
+ "choices": [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  {
359
+ "index": 0,
360
+ "message": output_message,
361
+ "finish_reason": finish_reason,
362
+ "logprobs": choice.get("logprobs"),
 
 
 
 
 
363
  }
 
364
  ],
365
+ "usage": usage,
366
  }
367
 
368
 
369
+ def _payload_sse(payload: dict[str, Any]) -> Iterator[str]:
370
+ choice = payload["choices"][0]
371
+ chunk_id = payload["id"]
372
+ created = payload["created"]
373
+ model = payload["model"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
+ def event(delta: dict[str, Any], finish_reason: str | None) -> str:
376
+ body = {
 
377
  "id": chunk_id,
378
  "object": "chat.completion.chunk",
379
+ "created": created,
380
+ "model": model,
381
  "choices": [
382
+ {
383
+ "index": 0,
384
+ "delta": delta,
385
+ "finish_reason": finish_reason,
386
+ "logprobs": None,
387
+ }
 
 
 
 
 
 
 
 
 
 
388
  ],
389
  }
390
+ return "data: " + json.dumps(body, ensure_ascii=False) + "\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ yield event({"role": "assistant", "content": None}, None)
393
+ message = choice["message"]
394
+ if message.get("tool_calls"):
395
+ yield event(
396
+ {"tool_calls": indexed_tool_calls(message["tool_calls"])}, None
 
397
  )
398
+ elif isinstance(message.get("content"), str) and message["content"]:
399
+ yield event({"content": message["content"]}, None)
400
+ yield event({}, choice["finish_reason"])
401
+ yield "data: [DONE]\n\n"
402
+
403
+
404
+ def _plain_stream_events(request: ChatCompletionRequest) -> Iterator[str]:
405
+ model = _ensure_model_loaded()
406
+ kwargs = _llama_kwargs(
407
+ request,
408
+ [dict(message) for message in request.messages],
409
+ [],
410
+ stream=True,
411
+ )
412
 
413
+ with _inference_lock:
414
+ chunks = model.create_chat_completion(**kwargs)
415
+ for chunk in chunks:
416
+ if not isinstance(chunk, dict):
417
+ continue
418
+ chunk["model"] = SETTINGS.model_alias
419
+ yield "data: " + json.dumps(
420
+ chunk, ensure_ascii=False
421
+ ) + "\n\n"
422
+ yield "data: [DONE]\n\n"
423
 
 
 
 
 
424
 
425
+ @app.middleware("http")
426
+ async def request_guard(request: Request, call_next):
427
+ content_length = request.headers.get("content-length")
428
+ if content_length:
429
+ try:
430
+ if int(content_length) > SETTINGS.max_request_bytes:
431
+ return JSONResponse(
432
+ status_code=413,
433
+ content={"error": {"message": "Request body too large"}},
434
+ )
435
+ except ValueError:
436
+ pass
437
 
438
+ if SETTINGS.api_key and request.url.path.startswith("/v1/"):
439
+ if request.headers.get("authorization") != "Bearer " + SETTINGS.api_key:
440
+ return JSONResponse(
441
+ status_code=401,
442
+ content={"error": {"message": "Invalid API key"}},
443
+ headers={"WWW-Authenticate": "Bearer"},
444
+ )
445
+ return await call_next(request)
446
 
447
 
448
+ @app.get("/")
449
+ async def root():
450
+ return {
451
+ "service": "Qwen3 CPU OpenAI API",
452
+ "status": "running",
453
+ "model": SETTINGS.model_alias,
454
+ "model_repo": SETTINGS.model_repo,
455
+ "model_state": _model_state,
456
+ "endpoints": [
457
+ "/health",
458
+ "/ready",
459
+ "/v1/models",
460
+ "/v1/chat/completions",
461
+ ],
462
+ }
463
 
464
 
465
+ @app.get("/health")
466
+ async def health():
467
+ return {
468
+ "status": "ok",
469
+ "model": SETTINGS.model_alias,
470
+ "model_state": _model_state,
471
+ "model_loaded": _model_loaded(),
472
+ "model_error": _model_error,
473
+ "n_ctx": SETTINGS.n_ctx,
474
+ "threads": SETTINGS.n_threads,
475
+ "llama_cpp_python": _version("llama-cpp-python"),
476
+ "huggingface_hub": _version("huggingface-hub"),
477
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
 
480
+ @app.get("/ready")
481
+ async def ready():
482
+ if not _model_loaded():
483
+ return JSONResponse(
484
+ status_code=503,
485
+ content={
486
+ "status": "not_ready",
487
+ "model_state": _model_state,
488
+ "model_error": _model_error,
489
+ },
490
+ )
491
+ return {"status": "ready", "model": SETTINGS.model_alias}
492
 
 
493
 
494
+ @app.get("/v1/models")
495
+ async def models():
496
+ return {
497
+ "object": "list",
498
+ "data": [
499
+ {
500
+ "id": model_id,
501
+ "object": "model",
502
+ "created": 0,
503
+ "owned_by": "Erinaldorodrigues",
504
+ "context_length": SETTINGS.n_ctx,
505
+ }
506
+ for model_id in SETTINGS.model_aliases
507
+ ],
508
+ }
509
 
 
 
 
 
510
 
511
+ @app.post("/v1/chat/completions")
512
+ async def chat_completions(request: ChatCompletionRequest):
513
+ _validate_request(request)
514
+ normalized_tools = normalize_tools(request.tools or [])
515
 
516
+ if normalized_tools:
517
+ try:
518
+ payload = await run_in_threadpool(_completion_payload, request)
519
+ except HTTPException:
520
+ raise
521
+ except Exception as error:
522
+ traceback.print_exc()
523
+ raise HTTPException(
524
+ status_code=503, detail=_short_error(error)
525
+ ) from error
526
+
527
+ if request.stream:
528
+ return StreamingResponse(
529
+ _payload_sse(payload),
530
+ media_type="text/event-stream",
531
+ headers={
532
+ "Cache-Control": "no-cache",
533
+ "X-Accel-Buffering": "no",
534
+ },
535
+ )
536
+ return JSONResponse(payload)
537
 
538
+ if request.stream:
539
+ try:
540
+ await run_in_threadpool(_ensure_model_loaded)
541
+ except Exception as error:
542
+ raise HTTPException(
543
+ status_code=503, detail=_short_error(error)
544
+ ) from error
545
+ return StreamingResponse(
546
+ _plain_stream_events(request),
547
+ media_type="text/event-stream",
548
+ headers={
549
+ "Cache-Control": "no-cache",
550
+ "X-Accel-Buffering": "no",
551
+ },
552
+ )
553
 
 
554
  try:
555
+ payload = await run_in_threadpool(_completion_payload, request)
556
+ return JSONResponse(payload)
557
+ except HTTPException:
558
+ raise
559
+ except Exception as error:
560
  traceback.print_exc()
561
+ raise HTTPException(
562
+ status_code=503, detail=_short_error(error)
563
+ ) from error
564
 
565
 
566
+ @app.on_event("startup")
567
+ async def optional_preload():
568
+ if SETTINGS.preload_model:
569
+ try:
570
+ await run_in_threadpool(_ensure_model_loaded)
571
+ except Exception:
572
+ # Keep /health alive for diagnosis instead of crashing the Space.
573
+ traceback.print_exc()
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,12 +1,4 @@
1
- # CPU-only OpenClaude backend. No torch, CUDA, GPTQModel, accelerate or spaces.
2
- # The Docker build compiles llama-cpp-python from its pinned PyPI source release.
3
-
4
- fastapi>=0.115,<1
5
- pydantic>=2.10,<3
6
- httpx>=0.27,<1
7
- gradio==6.22.0
8
- huggingface_hub>=0.34,<2
9
- transformers==5.14.1
10
- tokenizers>=0.21
11
- sentencepiece>=0.2
12
- llama-cpp-python==0.3.34
 
1
+ fastapi==0.141.1
2
+ uvicorn==0.52.1
3
+ pydantic==2.13.4
4
+ huggingface_hub==1.27.0
 
 
 
 
 
 
 
 
settings.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def _env_bool(name: str, default: bool) -> bool:
8
+ raw = os.getenv(name)
9
+ if raw is None:
10
+ return default
11
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
12
+
13
+
14
+ def _env_int(name: str, default: int, minimum: int, maximum: int) -> int:
15
+ raw = os.getenv(name, str(default)).strip()
16
+ try:
17
+ value = int(raw)
18
+ except ValueError as exc:
19
+ raise RuntimeError(f"{name} must be an integer") from exc
20
+ if not minimum <= value <= maximum:
21
+ raise RuntimeError(f"{name} must be between {minimum} and {maximum}")
22
+ return value
23
+
24
+
25
+ def _aliases(primary: str, raw: str) -> tuple[str, ...]:
26
+ values = [primary]
27
+ for value in raw.split(","):
28
+ value = value.strip()
29
+ if value and value not in values:
30
+ values.append(value)
31
+ return tuple(values)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Settings:
36
+ model_repo: str
37
+ model_file: str
38
+ model_alias: str
39
+ model_aliases: tuple[str, ...]
40
+ n_ctx: int
41
+ max_new_tokens: int
42
+ n_threads: int
43
+ n_threads_batch: int
44
+ n_batch: int
45
+ n_ubatch: int
46
+ model_min_bytes: int
47
+ api_key: str
48
+ max_request_bytes: int
49
+ model_retry_cooldown_seconds: int
50
+ preload_model: bool
51
+
52
+ @classmethod
53
+ def from_env(cls) -> "Settings":
54
+ model_alias = os.getenv("MODEL_ALIAS", "qwen-coder").strip() or "qwen-coder"
55
+ aliases = _aliases(
56
+ model_alias,
57
+ os.getenv(
58
+ "MODEL_ALIASES",
59
+ "qwen3-4b,Qwen3-4B-Instruct-2507,"
60
+ "unsloth/Qwen3-4B-Instruct-2507-GGUF",
61
+ ),
62
+ )
63
+ cpu_count = os.cpu_count() or 2
64
+ default_threads = min(2, cpu_count)
65
+ return cls(
66
+ model_repo=os.getenv(
67
+ "MODEL_REPO", "unsloth/Qwen3-4B-Instruct-2507-GGUF"
68
+ ).strip(),
69
+ model_file=os.getenv(
70
+ "MODEL_FILE", "Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
71
+ ).strip(),
72
+ model_alias=model_alias,
73
+ model_aliases=aliases,
74
+ n_ctx=_env_int("N_CTX", 8192, 1024, 32768),
75
+ max_new_tokens=_env_int("MAX_NEW_TOKENS", 2048, 1, 8192),
76
+ n_threads=_env_int("N_THREADS", default_threads, 1, 64),
77
+ n_threads_batch=_env_int(
78
+ "N_THREADS_BATCH", default_threads, 1, 64
79
+ ),
80
+ n_batch=_env_int("N_BATCH", 128, 16, 2048),
81
+ n_ubatch=_env_int("N_UBATCH", 64, 16, 2048),
82
+ model_min_bytes=_env_int(
83
+ "MODEL_MIN_BYTES",
84
+ 2_000_000_000,
85
+ 1_000_000,
86
+ 20_000_000_000,
87
+ ),
88
+ api_key=os.getenv("API_KEY", "").strip(),
89
+ max_request_bytes=_env_int(
90
+ "MAX_REQUEST_BYTES", 2_000_000, 32_768, 20_000_000
91
+ ),
92
+ model_retry_cooldown_seconds=_env_int(
93
+ "MODEL_RETRY_COOLDOWN_SECONDS", 30, 0, 3600
94
+ ),
95
+ preload_model=_env_bool("PRELOAD_MODEL", False),
96
+ )
smoke_test.sh ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BASE_URL="${1:-https://erinaldorodrigues-vscode.hf.space}"
5
+ API_KEY="${API_KEY:-local}"
6
+
7
+ echo "== health =="
8
+ curl -fsS "$BASE_URL/health"
9
+ echo
10
+
11
+ echo "== models =="
12
+ curl -fsS -H "Authorization: Bearer $API_KEY" "$BASE_URL/v1/models"
13
+ echo
14
+
15
+ echo "== chat =="
16
+ curl -fsS "$BASE_URL/v1/chat/completions" \
17
+ -H "Authorization: Bearer $API_KEY" \
18
+ -H "Content-Type: application/json" \
19
+ -d '{
20
+ "model":"qwen-coder",
21
+ "messages":[{"role":"user","content":"Responda apenas: OK"}],
22
+ "temperature":0,
23
+ "max_tokens":16
24
+ }'
25
+ echo
26
+
27
+ echo "== Bash tool call =="
28
+ curl -fsS "$BASE_URL/v1/chat/completions" \
29
+ -H "Authorization: Bearer $API_KEY" \
30
+ -H "Content-Type: application/json" \
31
+ -d '{
32
+ "model":"qwen-coder",
33
+ "messages":[{"role":"user","content":"Use Bash para executar pwd. Não simule."}],
34
+ "temperature":0,
35
+ "max_tokens":256,
36
+ "tool_choice":"required",
37
+ "parallel_tool_calls":false,
38
+ "tools":[{
39
+ "type":"function",
40
+ "function":{
41
+ "name":"Bash",
42
+ "description":"Execute a shell command",
43
+ "parameters":{
44
+ "type":"object",
45
+ "properties":{"command":{"type":"string"}},
46
+ "required":["command"]
47
+ }
48
+ }
49
+ }]
50
+ }'
51
+ echo
tests/test_settings.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from settings import Settings
8
+
9
+
10
+ class SettingsTests(unittest.TestCase):
11
+ def test_defaults_are_cpu_safe(self):
12
+ with mock.patch.dict(os.environ, {}, clear=True):
13
+ settings = Settings.from_env()
14
+ self.assertEqual(settings.n_ctx, 8192)
15
+ self.assertEqual(settings.n_threads, min(2, os.cpu_count() or 2))
16
+ self.assertEqual(settings.model_alias, "qwen-coder")
17
+ self.assertTrue(settings.model_file.endswith("Q4_K_M.gguf"))
18
+
19
+ def test_invalid_context_is_rejected(self):
20
+ with mock.patch.dict(os.environ, {"N_CTX": "999999"}, clear=True):
21
+ with self.assertRaises(RuntimeError):
22
+ Settings.from_env()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ unittest.main()
tests/test_static_contract.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ import unittest
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ def executable_docker_lines() -> str:
11
+ lines = []
12
+ for line in (ROOT / "Dockerfile").read_text().splitlines():
13
+ stripped = line.strip()
14
+ if stripped and not stripped.startswith("#"):
15
+ lines.append(stripped.casefold())
16
+ return "\n".join(lines)
17
+
18
+
19
+ class StaticContractTests(unittest.TestCase):
20
+ def test_app_compiles(self):
21
+ ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
22
+
23
+ def test_no_heavy_transformers_stack(self):
24
+ requirements = (ROOT / "requirements.txt").read_text().casefold()
25
+ for forbidden in ("torch", "transformers", "gradio", "sentencepiece"):
26
+ self.assertNotIn(forbidden, requirements)
27
+
28
+ def test_docker_never_source_builds_llama_cpp(self):
29
+ docker = executable_docker_lines()
30
+ self.assertNotIn("--no-binary", docker)
31
+ self.assertNotIn("build-essential", docker)
32
+ self.assertNotIn("cmake", docker)
33
+ self.assertNotIn("ninja", docker)
34
+ self.assertIn("--only-binary=:all:", docker)
35
+ self.assertIn(
36
+ "https://abetlen.github.io/llama-cpp-python/whl/cpu",
37
+ docker,
38
+ )
39
+ self.assertIn("llama-cpp-python==${llama_cpp_version}", docker)
40
+
41
+ def test_runtime_routes_exist(self):
42
+ source = (ROOT / "app.py").read_text()
43
+ for path in (
44
+ "/health",
45
+ "/ready",
46
+ "/v1/models",
47
+ "/v1/chat/completions",
48
+ ):
49
+ self.assertIn(path, source)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ unittest.main()
tests/test_tooling.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+
6
+ from tooling import (
7
+ build_tool_plan,
8
+ extract_tool_calls,
9
+ has_recent_tool_result,
10
+ normalize_tools,
11
+ )
12
+
13
+ BASH = {
14
+ "type": "function",
15
+ "function": {
16
+ "name": "Bash",
17
+ "description": "run shell",
18
+ "parameters": {
19
+ "type": "object",
20
+ "properties": {"command": {"type": "string"}},
21
+ "required": ["command"],
22
+ },
23
+ },
24
+ }
25
+
26
+ READ = {
27
+ "type": "function",
28
+ "function": {
29
+ "name": "Read",
30
+ "description": "read file",
31
+ "parameters": {
32
+ "type": "object",
33
+ "properties": {"path": {"type": "string"}},
34
+ "required": ["path"],
35
+ },
36
+ },
37
+ }
38
+
39
+
40
+ class ToolingTests(unittest.TestCase):
41
+ def test_native_qwen_tag_is_structured(self):
42
+ text = '<tool_call>{"name":"Bash","arguments":{"command":"pwd"}}</tool_call>'
43
+ calls = extract_tool_calls(text, {"Bash"})
44
+ self.assertEqual(len(calls), 1)
45
+ self.assertEqual(calls[0]["function"]["name"], "Bash")
46
+ self.assertEqual(
47
+ json.loads(calls[0]["function"]["arguments"]),
48
+ {"command": "pwd"},
49
+ )
50
+
51
+ def test_raw_json_is_structured(self):
52
+ text = '{"name":"Bash","arguments":{"command":"sudo apt update"}}'
53
+ self.assertEqual(len(extract_tool_calls(text, {"Bash"})), 1)
54
+
55
+ def test_short_prefix_raw_json_is_structured(self):
56
+ text = (
57
+ 'Vou executar agora.\n'
58
+ '{"name":"Bash","arguments":{"command":"sudo apt update"}}'
59
+ )
60
+ self.assertEqual(len(extract_tool_calls(text, {"Bash"})), 1)
61
+
62
+ def test_multiple_native_calls(self):
63
+ text = (
64
+ '<tool_call>{"name":"Read","arguments":{"path":"a"}}</tool_call>'
65
+ '<tool_call>{"name":"Read","arguments":{"path":"b"}}</tool_call>'
66
+ )
67
+ self.assertEqual(len(extract_tool_calls(text, {"Read"})), 2)
68
+
69
+ def test_undeclared_tool_is_rejected(self):
70
+ text = '<tool_call>{"name":"DeleteAll","arguments":{}}</tool_call>'
71
+ self.assertEqual(extract_tool_calls(text, {"Bash"}), [])
72
+
73
+ def test_duplicate_call_is_deduplicated(self):
74
+ text = (
75
+ '<tool_call>{"name":"Bash","arguments":{"command":"pwd"}}</tool_call>'
76
+ '<tool_call>{"name":"Bash","arguments":{"command":"pwd"}}</tool_call>'
77
+ )
78
+ self.assertEqual(len(extract_tool_calls(text, {"Bash"})), 1)
79
+
80
+ def test_required_first_turn_remains_required(self):
81
+ plan = build_tool_plan(
82
+ [{"role": "user", "content": "atualizar tudo sem perguntas"}],
83
+ [BASH],
84
+ "required",
85
+ False,
86
+ )
87
+ self.assertEqual(plan.mode, "required")
88
+ self.assertEqual(plan.tools[0]["function"]["name"], "Bash")
89
+
90
+ def test_required_after_tool_result_downgrades_auto(self):
91
+ messages = [
92
+ {"role": "user", "content": "execute pwd"},
93
+ {
94
+ "role": "assistant",
95
+ "content": None,
96
+ "tool_calls": [{
97
+ "id": "call_1",
98
+ "type": "function",
99
+ "function": {
100
+ "name": "Bash",
101
+ "arguments": '{"command":"pwd"}',
102
+ },
103
+ }],
104
+ },
105
+ {
106
+ "role": "tool",
107
+ "tool_call_id": "call_1",
108
+ "content": "/home/user\n",
109
+ },
110
+ ]
111
+ self.assertTrue(has_recent_tool_result(messages))
112
+ plan = build_tool_plan(messages, [BASH], "required", False)
113
+ self.assertEqual(plan.mode, "auto")
114
+
115
+ def test_required_greeting_does_not_force_tool(self):
116
+ plan = build_tool_plan(
117
+ [{"role": "user", "content": "oi"}],
118
+ [BASH],
119
+ "required",
120
+ False,
121
+ )
122
+ self.assertEqual(plan.mode, "none")
123
+ self.assertEqual(plan.tools, [])
124
+
125
+ def test_forced_tool_is_restricted(self):
126
+ plan = build_tool_plan(
127
+ [{"role": "user", "content": "leia o arquivo"}],
128
+ [BASH, READ],
129
+ {"type": "function", "function": {"name": "Read"}},
130
+ False,
131
+ )
132
+ self.assertEqual(plan.mode, "forced")
133
+ self.assertEqual(
134
+ [x["function"]["name"] for x in plan.tools], ["Read"]
135
+ )
136
+
137
+ def test_normalize_invalid_tools(self):
138
+ self.assertEqual(
139
+ len(normalize_tools([{}, {"type": "other"}, BASH])), 1
140
+ )
141
+
142
+
143
+ if __name__ == "__main__":
144
+ unittest.main()
tooling.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ _TOOL_TAG_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", re.I | re.S)
10
+ _GREETING_RE = re.compile(
11
+ r"^\s*(oi|ol[aá]|hello|hi|hey|bom dia|boa tarde|boa noite)[!.?,\s]*$",
12
+ re.I,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ToolPlan:
18
+ tools: list[dict[str, Any]]
19
+ mode: str
20
+ instruction: str | None
21
+
22
+
23
+ def normalize_tools(raw_tools: object) -> list[dict[str, Any]]:
24
+ if not isinstance(raw_tools, list):
25
+ return []
26
+ output: list[dict[str, Any]] = []
27
+ seen: set[str] = set()
28
+ for item in raw_tools:
29
+ if not isinstance(item, dict) or item.get("type") != "function":
30
+ continue
31
+ function = item.get("function")
32
+ if not isinstance(function, dict):
33
+ continue
34
+ name = function.get("name")
35
+ if not isinstance(name, str) or not name.strip():
36
+ continue
37
+ name = name.strip()
38
+ if name in seen:
39
+ continue
40
+ parameters = function.get("parameters")
41
+ if not isinstance(parameters, dict):
42
+ parameters = {"type": "object", "properties": {}}
43
+ description = function.get("description")
44
+ if not isinstance(description, str):
45
+ description = ""
46
+ if len(description) > 4000:
47
+ description = description[:3997] + "..."
48
+ output.append(
49
+ {
50
+ "type": "function",
51
+ "function": {
52
+ "name": name,
53
+ "description": description,
54
+ "parameters": parameters,
55
+ },
56
+ }
57
+ )
58
+ seen.add(name)
59
+ return output
60
+
61
+
62
+ def tool_names(tools: list[dict[str, Any]]) -> set[str]:
63
+ return {
64
+ tool["function"]["name"]
65
+ for tool in tools
66
+ if isinstance(tool, dict)
67
+ and isinstance(tool.get("function"), dict)
68
+ and isinstance(tool["function"].get("name"), str)
69
+ }
70
+
71
+
72
+ def _content_text(message: dict[str, Any]) -> str:
73
+ content = message.get("content")
74
+ if isinstance(content, str):
75
+ return content
76
+ if isinstance(content, list):
77
+ parts: list[str] = []
78
+ for part in content:
79
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
80
+ parts.append(part["text"])
81
+ return "\n".join(parts)
82
+ return ""
83
+
84
+
85
+ def last_user_text(messages: list[dict[str, Any]]) -> str:
86
+ for message in reversed(messages):
87
+ if isinstance(message, dict) and message.get("role") == "user":
88
+ return _content_text(message).strip()
89
+ return ""
90
+
91
+
92
+ def is_simple_greeting(messages: list[dict[str, Any]]) -> bool:
93
+ text = last_user_text(messages)
94
+ return bool(text and _GREETING_RE.fullmatch(text))
95
+
96
+
97
+ def has_recent_tool_result(messages: list[dict[str, Any]]) -> bool:
98
+ for message in reversed(messages):
99
+ if not isinstance(message, dict):
100
+ continue
101
+ role = message.get("role")
102
+ if role == "system":
103
+ continue
104
+ if role == "tool":
105
+ return True
106
+ if role == "user":
107
+ return "<tool_response>" in _content_text(message)
108
+ return False
109
+ return False
110
+
111
+
112
+ def _forced_tool_name(requested: object) -> str | None:
113
+ if not isinstance(requested, dict) or requested.get("type") != "function":
114
+ return None
115
+ function = requested.get("function")
116
+ if not isinstance(function, dict):
117
+ return None
118
+ name = function.get("name")
119
+ return name.strip() if isinstance(name, str) and name.strip() else None
120
+
121
+
122
+ def build_tool_plan(
123
+ messages: list[dict[str, Any]],
124
+ tools: list[dict[str, Any]],
125
+ requested_choice: object,
126
+ parallel_tool_calls: bool | None,
127
+ ) -> ToolPlan:
128
+ if not tools:
129
+ return ToolPlan([], "none", None)
130
+
131
+ names = tool_names(tools)
132
+ forced = _forced_tool_name(requested_choice)
133
+
134
+ if forced:
135
+ if forced not in names:
136
+ raise ValueError(f"Requested tool is not available: {forced}")
137
+ selected = [t for t in tools if t["function"]["name"] == forced]
138
+ mode = "forced"
139
+ elif isinstance(requested_choice, str):
140
+ choice = requested_choice.casefold()
141
+ if choice == "none":
142
+ return ToolPlan([], "none", None)
143
+ if choice == "required":
144
+ if is_simple_greeting(messages):
145
+ return ToolPlan([], "none", None)
146
+ # OpenClaude can keep "required" on the turn immediately after a
147
+ # real tool result. Auto lets Qwen synthesize or call another tool.
148
+ mode = "auto" if has_recent_tool_result(messages) else "required"
149
+ selected = tools
150
+ elif choice == "auto":
151
+ mode = "auto"
152
+ selected = tools
153
+ else:
154
+ raise ValueError(f"Unsupported tool_choice: {requested_choice}")
155
+ elif requested_choice is None:
156
+ mode = "auto"
157
+ selected = tools
158
+ else:
159
+ raise ValueError("Unsupported tool_choice")
160
+
161
+ lines = [
162
+ "Tool execution protocol:",
163
+ "- A tool is executed only when you emit the native tool-call format.",
164
+ "- Never print a tool JSON object as ordinary prose.",
165
+ "- Never claim a tool succeeded before a tool response is present.",
166
+ "- After a tool response, use the actual output; do not invent results.",
167
+ "- Do not repeat an identical successful call unless the returned output "
168
+ "shows that another execution is necessary.",
169
+ ]
170
+ if mode == "required":
171
+ lines.append(
172
+ "- For this turn you MUST call at least one provided tool before "
173
+ "giving a final answer."
174
+ )
175
+ elif mode == "forced":
176
+ lines.append(
177
+ f"- For this turn you MUST call the tool "
178
+ f"{selected[0]['function']['name']}."
179
+ )
180
+ if parallel_tool_calls is False:
181
+ lines.append("- Emit exactly one tool call in this turn.")
182
+ else:
183
+ lines.append("- Multiple independent tool calls are allowed when useful.")
184
+
185
+ return ToolPlan(selected, mode, "\n".join(lines))
186
+
187
+
188
+ def inject_system_instruction(
189
+ messages: list[dict[str, Any]], instruction: str | None
190
+ ) -> list[dict[str, Any]]:
191
+ copied = [dict(message) for message in messages]
192
+ if not instruction:
193
+ return copied
194
+ for index, message in enumerate(copied):
195
+ if (
196
+ message.get("role") == "system"
197
+ and isinstance(message.get("content"), str)
198
+ ):
199
+ copied[index] = {
200
+ **message,
201
+ "content": message["content"].rstrip() + "\n\n" + instruction,
202
+ }
203
+ return copied
204
+ return [{"role": "system", "content": instruction}, *copied]
205
+
206
+
207
+ def _json_sequence(blob: str) -> list[object]:
208
+ decoder = json.JSONDecoder()
209
+ output: list[object] = []
210
+ index = 0
211
+ while index < len(blob):
212
+ while index < len(blob) and (
213
+ blob[index].isspace() or blob[index] in ",;"
214
+ ):
215
+ index += 1
216
+ if index >= len(blob):
217
+ break
218
+ try:
219
+ value, end = decoder.raw_decode(blob, index)
220
+ except json.JSONDecodeError:
221
+ next_open = blob.find("{", index + 1)
222
+ if next_open < 0:
223
+ break
224
+ index = next_open
225
+ continue
226
+ output.append(value)
227
+ index = end
228
+ return output
229
+
230
+
231
+ def _candidate_objects(text: str) -> list[object]:
232
+ tagged = _TOOL_TAG_RE.findall(text)
233
+ if tagged:
234
+ values: list[object] = []
235
+ for block in tagged:
236
+ values.extend(_json_sequence(block.strip()))
237
+ return values
238
+
239
+ stripped = text.strip()
240
+ if stripped.startswith("{"):
241
+ return _json_sequence(stripped)
242
+
243
+ first = stripped.find("{")
244
+ if 0 <= first <= 160:
245
+ prefix = stripped[:first]
246
+ rest = stripped[first:]
247
+ if (
248
+ '"name"' in rest[:300]
249
+ and '"arguments"' in rest[:500]
250
+ and len(prefix.split()) <= 25
251
+ ):
252
+ return _json_sequence(rest)
253
+ return []
254
+
255
+
256
+ def _canonical_arguments(arguments: object) -> str:
257
+ if isinstance(arguments, str):
258
+ try:
259
+ parsed = json.loads(arguments)
260
+ except json.JSONDecodeError:
261
+ return json.dumps({"value": arguments}, ensure_ascii=False)
262
+ return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
263
+ if arguments is None:
264
+ arguments = {}
265
+ return json.dumps(arguments, ensure_ascii=False, separators=(",", ":"))
266
+
267
+
268
+ def _normalize_candidate(
269
+ candidate: object, allowed_names: set[str]
270
+ ) -> tuple[str, str] | None:
271
+ if not isinstance(candidate, dict):
272
+ return None
273
+ if isinstance(candidate.get("function"), dict):
274
+ function = candidate["function"]
275
+ name = function.get("name")
276
+ arguments = function.get("arguments", {})
277
+ else:
278
+ name = candidate.get("name")
279
+ arguments = candidate.get("arguments", {})
280
+ if not isinstance(name, str) or name not in allowed_names:
281
+ return None
282
+ return name, _canonical_arguments(arguments)
283
+
284
+
285
+ def extract_tool_calls(
286
+ text: str,
287
+ allowed_names: set[str],
288
+ existing_tool_calls: object = None,
289
+ ) -> list[dict[str, Any]]:
290
+ normalized: list[tuple[str, str]] = []
291
+
292
+ if isinstance(existing_tool_calls, list):
293
+ for item in existing_tool_calls:
294
+ pair = _normalize_candidate(item, allowed_names)
295
+ if pair:
296
+ normalized.append(pair)
297
+
298
+ for candidate in _candidate_objects(text):
299
+ pair = _normalize_candidate(candidate, allowed_names)
300
+ if pair:
301
+ normalized.append(pair)
302
+
303
+ output: list[dict[str, Any]] = []
304
+ seen: set[tuple[str, str]] = set()
305
+ for name, arguments in normalized:
306
+ signature = (name, arguments)
307
+ if signature in seen:
308
+ continue
309
+ seen.add(signature)
310
+ output.append(
311
+ {
312
+ "id": "call_" + uuid.uuid4().hex,
313
+ "type": "function",
314
+ "function": {"name": name, "arguments": arguments},
315
+ }
316
+ )
317
+ return output
318
+
319
+
320
+ def indexed_tool_calls(
321
+ tool_calls: list[dict[str, Any]]
322
+ ) -> list[dict[str, Any]]:
323
+ return [{"index": index, **call} for index, call in enumerate(tool_calls)]