aiqknow commited on
Commit
35205e8
·
verified ·
1 Parent(s): a088295

Upload 97 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .README.md.swp +0 -0
  2. .env.example +26 -0
  3. .github/workflows/ci.yml +20 -0
  4. .github/workflows/release.yml +229 -0
  5. .gitignore +21 -0
  6. CONTRIBUTING.md +37 -0
  7. DOCKER.md +41 -0
  8. Dockerfile +20 -0
  9. LICENSE +21 -0
  10. README.md +209 -10
  11. build.py +225 -0
  12. build/lib/chatmock/__init__.py +5 -0
  13. build/lib/chatmock/app.py +56 -0
  14. build/lib/chatmock/cli.py +425 -0
  15. build/lib/chatmock/config.py +48 -0
  16. build/lib/chatmock/fast_mode.py +92 -0
  17. build/lib/chatmock/http.py +24 -0
  18. build/lib/chatmock/limits.py +200 -0
  19. build/lib/chatmock/model_registry.py +198 -0
  20. build/lib/chatmock/models.py +26 -0
  21. build/lib/chatmock/oauth.py +340 -0
  22. build/lib/chatmock/prompt.md +1 -0
  23. build/lib/chatmock/prompt_gpt5_codex.md +1 -0
  24. build/lib/chatmock/reasoning.py +79 -0
  25. build/lib/chatmock/responses_api.py +243 -0
  26. build/lib/chatmock/routes_ollama.py +585 -0
  27. build/lib/chatmock/routes_openai.py +738 -0
  28. build/lib/chatmock/session.py +312 -0
  29. build/lib/chatmock/transform.py +149 -0
  30. build/lib/chatmock/upstream.py +181 -0
  31. build/lib/chatmock/utils.py +874 -0
  32. build/lib/chatmock/version.py +4 -0
  33. build/lib/chatmock/websocket_routes.py +225 -0
  34. chatmock.egg-info/PKG-INFO +200 -0
  35. chatmock.egg-info/SOURCES.txt +34 -0
  36. chatmock.egg-info/dependency_links.txt +1 -0
  37. chatmock.egg-info/entry_points.txt +2 -0
  38. chatmock.egg-info/requires.txt +17 -0
  39. chatmock.egg-info/top_level.txt +1 -0
  40. chatmock.py +7 -0
  41. chatmock/__init__.py +5 -0
  42. chatmock/__pycache__/__init__.cpython-314.pyc +0 -0
  43. chatmock/__pycache__/app.cpython-314.pyc +0 -0
  44. chatmock/__pycache__/cli.cpython-314.pyc +0 -0
  45. chatmock/__pycache__/config.cpython-314.pyc +0 -0
  46. chatmock/__pycache__/fast_mode.cpython-314.pyc +0 -0
  47. chatmock/__pycache__/http.cpython-314.pyc +0 -0
  48. chatmock/__pycache__/limits.cpython-314.pyc +0 -0
  49. chatmock/__pycache__/model_registry.cpython-314.pyc +0 -0
  50. chatmock/__pycache__/models.cpython-314.pyc +0 -0
.README.md.swp ADDED
Binary file (12.3 kB). View file
 
.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Port
2
+ PORT=8000
3
+
4
+ # Image
5
+ CHATMOCK_IMAGE=storagetime/chatmock:latest
6
+
7
+ # Auth dir
8
+ CHATGPT_LOCAL_HOME=/data
9
+
10
+ # show request/stream logs
11
+ VERBOSE=false
12
+
13
+ # OAuth client id (modify only if you know what you're doing)
14
+ # CHATGPT_LOCAL_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
15
+
16
+ # Reasoning controls
17
+ CHATGPT_LOCAL_REASONING_EFFORT=medium # none|minimal|low|medium|high|xhigh
18
+ CHATGPT_LOCAL_REASONING_SUMMARY=auto # auto|concise|detailed|none
19
+ CHATGPT_LOCAL_REASONING_COMPAT=think-tags # legacy|o3|think-tags|current
20
+ CHATGPT_LOCAL_EXPOSE_REASONING_MODELS=false
21
+
22
+ # Enable default web search tool
23
+ CHATGPT_LOCAL_ENABLE_WEB_SEARCH=false
24
+
25
+ # Force a specific model name
26
+ # CHATGPT_LOCAL_DEBUG_MODEL=gpt-5.4
.github/workflows/ci.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.11"
17
+ - uses: astral-sh/setup-uv@v5
18
+ - run: uv pip install --system .
19
+ - run: python -m unittest discover -s tests
20
+ - run: uv build
.github/workflows/release.yml ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ validate:
13
+ runs-on: ubuntu-latest
14
+ outputs:
15
+ version: ${{ steps.version.outputs.version }}
16
+ tag: ${{ steps.version.outputs.tag }}
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.11"
22
+ - id: version
23
+ run: |
24
+ VERSION="${GITHUB_REF_NAME#v}"
25
+ PACKAGE_VERSION="$(python - <<'PY'
26
+ import runpy
27
+ print(runpy.run_path("chatmock/version.py")["__version__"])
28
+ PY
29
+ )"
30
+ if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
31
+ echo "Tag version $VERSION does not match package version $PACKAGE_VERSION" >&2
32
+ exit 1
33
+ fi
34
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
35
+ echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
36
+ - uses: astral-sh/setup-uv@v5
37
+ - run: uv pip install --system .
38
+ - run: python -m unittest discover -s tests
39
+
40
+ build-python:
41
+ needs: validate
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: actions/setup-python@v5
46
+ with:
47
+ python-version: "3.11"
48
+ - uses: astral-sh/setup-uv@v5
49
+ - run: uv build
50
+ - uses: actions/upload-artifact@v4
51
+ with:
52
+ name: python-dist
53
+ path: dist/*
54
+
55
+ publish-pypi:
56
+ needs:
57
+ - validate
58
+ - build-python
59
+ runs-on: ubuntu-latest
60
+ permissions:
61
+ id-token: write
62
+ steps:
63
+ - uses: actions/download-artifact@v4
64
+ with:
65
+ name: python-dist
66
+ path: dist
67
+ - uses: pypa/gh-action-pypi-publish@release/v1
68
+ with:
69
+ packages-dir: dist
70
+
71
+ build-windows:
72
+ needs: validate
73
+ runs-on: windows-latest
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: actions/setup-python@v5
77
+ with:
78
+ python-version: "3.11"
79
+ - run: python -m pip install --upgrade pip
80
+ - run: python -m pip install ".[gui]"
81
+ - run: python build.py --name ChatMock
82
+ - run: Compress-Archive -Path dist/ChatMock -DestinationPath dist/ChatMock-windows.zip
83
+ shell: pwsh
84
+ - uses: actions/upload-artifact@v4
85
+ with:
86
+ name: windows-gui
87
+ path: dist/ChatMock-windows.zip
88
+
89
+ build-macos:
90
+ needs: validate
91
+ runs-on: macos-latest
92
+ env:
93
+ APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
94
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
95
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
96
+ APPLE_ID: ${{ secrets.APPLE_ID }}
97
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
98
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
99
+ steps:
100
+ - uses: actions/checkout@v4
101
+ - uses: actions/setup-python@v5
102
+ with:
103
+ python-version: "3.11"
104
+ - run: python -m pip install --upgrade pip
105
+ - run: python -m pip install ".[gui]"
106
+ - run: |
107
+ security create-keychain -p "$RUNNER_TEMP" build.keychain
108
+ security default-keychain -s build.keychain
109
+ security unlock-keychain -p "$RUNNER_TEMP" build.keychain
110
+ security set-keychain-settings -lut 21600 build.keychain
111
+ python - <<'PY'
112
+ import base64
113
+ import os
114
+ from pathlib import Path
115
+ data = os.environ["APPLE_CERTIFICATE_P12_BASE64"]
116
+ Path(os.environ["RUNNER_TEMP"], "chatmock-signing.p12").write_bytes(base64.b64decode(data))
117
+ PY
118
+ security import "$RUNNER_TEMP/chatmock-signing.p12" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
119
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$RUNNER_TEMP" build.keychain
120
+ - run: python build.py --name ChatMock
121
+ - run: codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" dist/ChatMock.app
122
+ - run: codesign --verify --deep --strict dist/ChatMock.app
123
+ - run: python build.py --name ChatMock --dmg-only
124
+ - run: codesign --force --sign "$APPLE_SIGNING_IDENTITY" dist/ChatMock.dmg
125
+ - run: codesign --verify --strict dist/ChatMock.dmg
126
+ - run: xcrun notarytool submit dist/ChatMock.dmg --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
127
+ - run: xcrun stapler staple dist/ChatMock.dmg
128
+ - run: xcrun stapler validate dist/ChatMock.dmg
129
+ - uses: actions/upload-artifact@v4
130
+ with:
131
+ name: macos-gui
132
+ path: dist/ChatMock.dmg
133
+
134
+ docker:
135
+ needs: validate
136
+ runs-on: ubuntu-latest
137
+ steps:
138
+ - uses: actions/checkout@v4
139
+ - uses: docker/setup-qemu-action@v3
140
+ - uses: docker/setup-buildx-action@v3
141
+ - uses: docker/login-action@v3
142
+ with:
143
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
144
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
145
+ - id: meta
146
+ uses: docker/metadata-action@v5
147
+ with:
148
+ images: storagetime/chatmock
149
+ tags: |
150
+ type=raw,value=latest
151
+ type=raw,value=${{ needs.validate.outputs.tag }}
152
+ type=raw,value=${{ needs.validate.outputs.version }}
153
+ - uses: docker/build-push-action@v6
154
+ with:
155
+ context: .
156
+ platforms: linux/amd64,linux/arm64
157
+ push: true
158
+ tags: ${{ steps.meta.outputs.tags }}
159
+ labels: ${{ steps.meta.outputs.labels }}
160
+
161
+ homebrew:
162
+ needs: validate
163
+ runs-on: ubuntu-latest
164
+ steps:
165
+ - run: |
166
+ ARCHIVE_URL="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${GITHUB_REF_NAME}.tar.gz"
167
+ SHA256="$(curl -fsSL "$ARCHIVE_URL" | shasum -a 256 | awk '{print $1}')"
168
+ git clone "https://x-access-token:${{ secrets.HOMEBREW_TAP_TOKEN }}@github.com/RayBytes/homebrew-chatmock.git" tap
169
+ cd tap
170
+ cat <<EOF > chatmock.rb
171
+ class Chatmock < Formula
172
+ include Language::Python::Virtualenv
173
+
174
+ desc "OpenAI & Ollama compatible API powered by your ChatGPT plan"
175
+ homepage "https://github.com/RayBytes/ChatMock"
176
+ url "${ARCHIVE_URL}"
177
+ sha256 "${SHA256}"
178
+ license "MIT"
179
+ head "https://github.com/RayBytes/ChatMock.git", branch: "main"
180
+
181
+ depends_on "python@3.11"
182
+
183
+ def install
184
+ virtualenv_create(libexec, "python3.11")
185
+ system libexec/"bin/pip", "install", "."
186
+ bin.install_symlink libexec/"bin/chatmock"
187
+ end
188
+
189
+ def caveats
190
+ <<~EOS
191
+ To get started with ChatMock:
192
+ chatmock login
193
+ chatmock serve
194
+ EOS
195
+ end
196
+
197
+ test do
198
+ output = shell_output("#{bin}/chatmock --help 2>&1")
199
+ assert_match "ChatMock", output
200
+ end
201
+ end
202
+ EOF
203
+ git config user.name "github-actions[bot]"
204
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
205
+ git add chatmock.rb
206
+ git commit -m "chatmock ${GITHUB_REF_NAME}" || exit 0
207
+ git push
208
+
209
+ release-assets:
210
+ needs:
211
+ - validate
212
+ - build-python
213
+ - build-windows
214
+ - build-macos
215
+ - publish-pypi
216
+ - docker
217
+ - homebrew
218
+ runs-on: ubuntu-latest
219
+ steps:
220
+ - uses: actions/download-artifact@v4
221
+ with:
222
+ path: release-artifacts
223
+ - run: find release-artifacts -type f | sort
224
+ - uses: softprops/action-gh-release@v2
225
+ with:
226
+ files: |
227
+ release-artifacts/python-dist/*
228
+ release-artifacts/windows-gui/*
229
+ release-artifacts/macos-gui/*
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .env/
8
+ .venv/
9
+ venv/
10
+
11
+ # Packaging artifacts
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+
16
+ # Tool caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+
20
+ # OS clutter
21
+ .DS_Store
CONTRIBUTING.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to ChatMock
2
+
3
+ We welcome thoughtful improvements. This guide calls out the expectations that keep reviews quick and the project stable.
4
+
5
+ # How should I contribute?
6
+
7
+ ### Before changing code...
8
+ - Open an issue before large or risky efforts so scope is agreed up front.
9
+ - Keep pull requests focused and easy to follow & break sweeping changes into a series when possible.
10
+ - Treat documentation, code, and packaging (CLI, Docker, GUI) as a single surface (your updates should apply to all).
11
+
12
+ ### Getting Set Up
13
+ - Review the Quickstart section in README.md
14
+ - Go through the codebase, and ensure you understand the current codebase.
15
+ - Confirm you can log in and serve a local instance, then make a couple of sample requests to understand current behaviour so you know if it broke later on.
16
+
17
+ ### Working With Core Files
18
+ - `prompt.md` and related Codex harness files are sensitive. Do not modify them or move entry points without prior maintainer approval.
19
+ - Be cautious with parameter names, response payload shapes, and file locations consumed by downstream clients. Coordinate before changing them.
20
+ - When touching shared logic, update both OpenAI and Ollama routes, plus any CLI/GUI code that depends on the same behaviour.
21
+
22
+ ## Designing Features and Fixes
23
+ - Prefer opt-in flags or config switches for new capabilities & leave defaults unchanged until maintainers confirm the rollout plan.
24
+ - Document any limits, or external dependencies introduced by your change.
25
+ - Validate compatibility with popular clients (e.g. Jan, Raycast, custom OpenAI SDKs) when responses or streaming formats shift.
26
+
27
+ # Pull Request Checklist
28
+ - [ ] Rebased on the latest `main` and issue reference included when applicable.
29
+ - [ ] Manual verification steps captured under "How to try locally" in the PR body.
30
+ - [ ] README.md, DOCKER.md, and other docs updated—or explicitly noted as not required.
31
+ - [ ] No generated artefacts or caches staged (`build/`, `dist/`, `__pycache__/`, `.pytest_cache/`, etc.).
32
+ - [ ] Critical paths (`prompt.md`, routing modules, public parameter names) reviewed for unintended edits and discussed with maintainers if changes were necessary.
33
+
34
+ ## Need Help?
35
+ - If you're not sure about about scope, flags, or how to implement a certain feature, always create an issue before hand.
36
+
37
+ Thank you for you contribution!
DOCKER.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Deployment
2
+
3
+ ## Quick Start
4
+ 1) Setup env:
5
+ cp .env.example .env
6
+
7
+ 2) Login:
8
+ docker compose run --rm --service-ports chatmock-login login
9
+
10
+ - The command prints an auth URL, copy paste it into your browser.
11
+ - If your browser cannot reach the container's localhost callback, copy the full redirect URL from the browser address bar and paste it back into the terminal when prompted.
12
+ - Server should stop automatically once it receives the tokens and they are saved.
13
+
14
+ 3) Start the server:
15
+ docker compose up -d chatmock
16
+
17
+ 4) Free to use it in whichever chat app you like!
18
+
19
+ ## Configuration
20
+ Set options in `.env` or pass environment variables:
21
+ - `PORT`: Container listening port (default 8000)
22
+ - `CHATMOCK_IMAGE`: image tag to run (default `storagetime/chatmock:latest`)
23
+ - `VERBOSE`: `true|false` to enable request/stream logs
24
+ - `CHATGPT_LOCAL_REASONING_EFFORT`: minimal|low|medium|high|xhigh
25
+ - `CHATGPT_LOCAL_REASONING_SUMMARY`: auto|concise|detailed|none
26
+ - `CHATGPT_LOCAL_REASONING_COMPAT`: legacy|o3|think-tags|current
27
+ - `CHATGPT_LOCAL_FAST_MODE`: `true|false` to enable fast mode by default for supported models
28
+ - `CHATGPT_LOCAL_CLIENT_ID`: OAuth client id override (rarely needed)
29
+ - `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS`: `true|false` to add reasoning model variants to `/v1/models`
30
+ - `CHATGPT_LOCAL_ENABLE_WEB_SEARCH`: `true|false` to enable default web search tool
31
+
32
+ ## Logs
33
+ Set `VERBOSE=true` to include extra logging for troubleshooting upstream or chat app requests. Please include and use these logs when submitting bug reports.
34
+
35
+ ## Test
36
+
37
+ ```
38
+ curl -s http://localhost:8000/v1/chat/completions \
39
+ -H 'Content-Type: application/json' \
40
+ -d '{"model":"gpt-5-codex","messages":[{"role":"user","content":"Hello world!"}]}' | jq .
41
+ ```
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1
5
+
6
+ WORKDIR /app
7
+
8
+ COPY pyproject.toml README.md chatmock.py prompt.md prompt_gpt5_codex.md /app/
9
+ COPY chatmock /app/chatmock
10
+ RUN pip install --no-cache-dir .
11
+
12
+ RUN mkdir -p /data
13
+
14
+ COPY docker/entrypoint.sh /entrypoint.sh
15
+ RUN chmod +x /entrypoint.sh
16
+
17
+ EXPOSE 7860 1455
18
+
19
+ ENTRYPOINT ["/entrypoint.sh"]
20
+ CMD ["serve"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Game_Time
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,209 @@
1
- ---
2
- title: CheckMat
3
- emoji: 💻
4
- colorFrom: indigo
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ # ChatMock
4
+
5
+ **Allows Codex to work in your favourite chat apps and coding tools.**
6
+
7
+ [![PyPI](https://img.shields.io/pypi/v/chatmock?color=blue&label=pypi)](https://pypi.org/project/chatmock/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/chatmock)](https://pypi.org/project/chatmock/)
9
+ [![License](https://img.shields.io/github/license/RayBytes/ChatMock)](LICENSE)
10
+ [![Stars](https://img.shields.io/github/stars/RayBytes/ChatMock?style=flat)](https://github.com/RayBytes/ChatMock/stargazers)
11
+ [![Last Commit](https://img.shields.io/github/last-commit/RayBytes/ChatMock)](https://github.com/RayBytes/ChatMock/commits/main)
12
+ [![Issues](https://img.shields.io/github/issues/RayBytes/ChatMock)](https://github.com/RayBytes/ChatMock/issues)
13
+
14
+ <br>
15
+
16
+
17
+ </div>
18
+
19
+ <br>
20
+
21
+ ## Install
22
+
23
+ #### Homebrew
24
+ ```bash
25
+ brew tap RayBytes/chatmock
26
+ brew install chatmock
27
+ ```
28
+
29
+ #### pipx / pip
30
+ ```bash
31
+ pipx install chatmock
32
+ ```
33
+
34
+ #### GUI
35
+ Download from [releases](https://github.com/RayBytes/ChatMock/releases) (macOS & Windows)
36
+
37
+ #### Docker
38
+ See [DOCKER.md](DOCKER.md)
39
+
40
+ #### Hugging Face
41
+ See [Hugging Face Deployment](#hugging-face-deployment)
42
+
43
+ <br>
44
+
45
+ ## Getting Started
46
+
47
+ ```bash
48
+ # 1. Sign in with your ChatGPT account
49
+ chatmock login
50
+
51
+ # 2. Start the server
52
+ chatmock serve
53
+ ```
54
+
55
+ The server runs at `http://127.0.0.1:8000` by default. Use `http://127.0.0.1:8000/v1` as your base URL for OpenAI-compatible apps.
56
+
57
+ <br>
58
+
59
+ ## Usage
60
+
61
+ <details open>
62
+ <summary><b>Python</b></summary>
63
+
64
+ ```python
65
+ from openai import OpenAI
66
+
67
+ client = OpenAI(
68
+ base_url="http://127.0.0.1:8000/v1",
69
+ api_key="anything" # not checked
70
+ )
71
+
72
+ response = client.chat.completions.create(
73
+ model="gpt-5.4",
74
+ messages=[{"role": "user", "content": "hello"}]
75
+ )
76
+ print(response.choices[0].message.content)
77
+ ```
78
+
79
+ </details>
80
+
81
+ <details>
82
+ <summary><b>cURL</b></summary>
83
+
84
+ ```bash
85
+ curl http://127.0.0.1:8000/v1/chat/completions \
86
+ -H "Content-Type: application/json" \
87
+ -d '{
88
+ "model": "gpt-5.4",
89
+ "messages": [{"role": "user", "content": "hello"}]
90
+ }'
91
+ ```
92
+
93
+ </details>
94
+
95
+ <details>
96
+ <summary><b>Custom API (Plain Text)</b></summary>
97
+
98
+ ```bash
99
+ # Request format: {"prompt": "..."}
100
+ # Response format: {"status": "success", "text": "..."}
101
+
102
+ curl http://127.0.0.1:8000/api \
103
+ -H "Content-Type: application/json" \
104
+ -d '{"prompt": "hello"}'
105
+
106
+ # You can also specify the model in the URL
107
+ curl http://127.0.0.1:8000/gpt-5.5/api \
108
+ -H "Content-Type: application/json" \
109
+ -d '{"prompt": "hello"}'
110
+ ```
111
+
112
+ </details>
113
+
114
+ <br>
115
+
116
+ ## Supported Models
117
+
118
+ - `gpt-5.5`
119
+ - `gpt-5.4`
120
+ - `gpt-5.4-mini`
121
+ - `gpt-5.2`
122
+ - `gpt-5.1`
123
+ - `gpt-5`
124
+ - `gpt-5.3-codex`
125
+ - `gpt-5.3-codex-spark`
126
+ - `gpt-5.2-codex`
127
+ - `gpt-5-codex`
128
+ - `gpt-5.1-codex`
129
+ - `gpt-5.1-codex-max`
130
+ - `gpt-5.1-codex-mini`
131
+ - `codex-mini`
132
+
133
+ <br>
134
+
135
+ ## Features
136
+
137
+ - Tool / function calling
138
+ - Vision / image input
139
+ - Thinking summaries (via think tags)
140
+ - Configurable thinking effort
141
+ - Fast mode for supported models
142
+ - Web search tool
143
+ - OpenAI-compatible `/v1/responses` (HTTP + WebSocket)
144
+ - Ollama-compatible endpoints
145
+ - Reasoning effort exposed as separate models (optional)
146
+
147
+ <br>
148
+
149
+ ## Configuration
150
+
151
+ All flags go after `chatmock serve`. These can also be set as environment variables.
152
+
153
+ | Flag | Env var | Options | Default | Description |
154
+ |------|---------|---------|---------|-------------|
155
+ | `--reasoning-effort` | `CHATGPT_LOCAL_REASONING_EFFORT` | none, minimal, low, medium, high, xhigh | medium | How hard the model thinks |
156
+ | `--reasoning-summary` | `CHATGPT_LOCAL_REASONING_SUMMARY` | auto, concise, detailed, none | auto | Thinking summary verbosity |
157
+ | `--reasoning-compat` | `CHATGPT_LOCAL_REASONING_COMPAT` | legacy, o3, think-tags | think-tags | How reasoning is returned to the client |
158
+ | `--fast-mode` | `CHATGPT_LOCAL_FAST_MODE` | true/false | false | Priority processing for supported models |
159
+ | `--enable-web-search` | `CHATGPT_LOCAL_ENABLE_WEB_SEARCH` | true/false | false | Allow the model to search the web |
160
+ | `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level as its own model |
161
+
162
+ <details>
163
+ <summary><b>Web search in a request</b></summary>
164
+
165
+ ```json
166
+ {
167
+ "model": "gpt-5.4",
168
+ "messages": [{"role": "user", "content": "latest news on ..."}],
169
+ "responses_tools": [{"type": "web_search"}],
170
+ "responses_tool_choice": "auto"
171
+ }
172
+ ```
173
+
174
+ </details>
175
+
176
+ <details>
177
+ <summary><b>Fast mode in a request</b></summary>
178
+
179
+ ```json
180
+ {
181
+ "model": "gpt-5.4",
182
+ "input": "summarize this",
183
+ "fast_mode": true
184
+ }
185
+ ```
186
+
187
+ </details>
188
+
189
+ <br>
190
+
191
+ ## Notes
192
+
193
+ Use responsibly and at your own risk. This project is not affiliated with OpenAI.
194
+
195
+ <br>
196
+
197
+ ## Hugging Face Deployment
198
+
199
+ 1. **Get Auth**: Run `python chatmock.py info --json` locally and copy the output.
200
+ 2. **Create Space**: Create a new **Docker** Space on Hugging Face.
201
+ 3. **Upload**: Upload all project files to the Space.
202
+ 4. **Secret**: In Space Settings, add a secret named `AUTH_JSON` and paste your auth data as the value.
203
+ 5. **Done**: Your API will be available at `https://<user>-<space>.hf.space/api`
204
+
205
+ <br>
206
+
207
+ ## Star History
208
+
209
+ [![Star History Chart](https://api.star-history.com/svg?repos=RayBytes/ChatMock&type=Timeline)](https://www.star-history.com/#RayBytes/ChatMock&Timeline)
build.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ import plistlib
11
+ from PIL import Image
12
+
13
+
14
+ ROOT = Path(__file__).parent.resolve()
15
+ BUILD_DIR = ROOT / "build"
16
+ ICONS_DIR = BUILD_DIR / "icons"
17
+
18
+
19
+ def info(msg: str) -> None:
20
+ print(f"[build] {msg}")
21
+
22
+
23
+ def ensure_dirs() -> None:
24
+ ICONS_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def load_icon_png(path: Path) -> Image.Image:
28
+ if Image is None:
29
+ raise RuntimeError("Pillow is required to process icons.")
30
+ img = Image.open(path).convert("RGBA")
31
+ size = max(img.width, img.height)
32
+ canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
33
+ x = (size - img.width) // 2
34
+ y = (size - img.height) // 2
35
+ canvas.paste(img, (x, y))
36
+ return canvas
37
+
38
+
39
+ def rounded(img: Image.Image, radius_ratio: float = 0.22) -> Image.Image:
40
+ if Image is None:
41
+ return img
42
+ w, h = img.size
43
+ r = int(min(w, h) * max(0.0, min(radius_ratio, 0.5)))
44
+ if r <= 0:
45
+ return img
46
+ mask = Image.new("L", (w, h), 0)
47
+ from PIL import ImageDraw
48
+ d = ImageDraw.Draw(mask)
49
+ d.rounded_rectangle((0, 0, w, h), radius=r, fill=255)
50
+ out = img.copy()
51
+ out.putalpha(mask)
52
+ return out
53
+
54
+
55
+ def make_windows_ico(src_png: Path, out_ico: Path, radius_ratio: float) -> Path:
56
+ info("Generating Windows .ico")
57
+ square = load_icon_png(src_png)
58
+ sizes = [16, 24, 32, 48, 64, 128, 256]
59
+ images = [rounded(square.resize((s, s), Image.LANCZOS), radius_ratio) for s in sizes]
60
+ images[0].save(out_ico, format="ICO", sizes=[(s, s) for s in sizes])
61
+ return out_ico
62
+
63
+
64
+ def make_macos_icns(src_png: Path, out_icns: Path, radius_ratio: float) -> Path:
65
+ info("Generating macOS .icns")
66
+ iconset = BUILD_DIR / "icon.iconset"
67
+ if iconset.exists():
68
+ shutil.rmtree(iconset)
69
+ iconset.mkdir(parents=True, exist_ok=True)
70
+
71
+ square = load_icon_png(src_png)
72
+ sizes = [16, 32, 64, 128, 256, 512, 1024]
73
+ mapping = {
74
+ 16: ["icon_16x16.png", "icon_32x32.png"],
75
+ 32: ["icon_16x16@2x.png"],
76
+ 64: ["icon_32x32@2x.png"],
77
+ 128: ["icon_128x128.png", "icon_256x256.png"],
78
+ 256: ["icon_128x128@2x.png"],
79
+ 512: ["icon_512x512.png"],
80
+ 1024:["icon_512x512@2x.png"],
81
+ }
82
+ for s in sizes:
83
+ img = rounded(square.resize((s, s), Image.LANCZOS), radius_ratio)
84
+ for name in mapping.get(s, []):
85
+ img.save(iconset / name, format="PNG")
86
+
87
+ try:
88
+ subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(out_icns)], check=True)
89
+ except Exception as e:
90
+ raise RuntimeError("Failed to create .icns. Ensure Xcode command line tools are installed (iconutil).\n"
91
+ f"Details: {e}")
92
+ finally:
93
+ shutil.rmtree(iconset, ignore_errors=True)
94
+ return out_icns
95
+
96
+
97
+ def pyinstaller_add_data_arg(src: Path, dest: str) -> str:
98
+ sep = ";" if os.name == "nt" else ":"
99
+ return f"{src}{sep}{dest}"
100
+
101
+
102
+ def run_pyinstaller(entry: Path, name: str, icon: Path | None, extra_data: list[tuple[Path, str]], bundle_id: str | None = None) -> None:
103
+ cmd = [
104
+ sys.executable, "-m", "PyInstaller",
105
+ "--windowed", "--noconfirm",
106
+ "--name", name,
107
+ ]
108
+ if bundle_id and platform.system().lower() == "darwin":
109
+ cmd += ["--osx-bundle-identifier", bundle_id]
110
+ if icon is not None:
111
+ cmd += ["--icon", str(icon)]
112
+ for (src, dest) in extra_data:
113
+ cmd += ["--add-data", pyinstaller_add_data_arg(src, dest)]
114
+ cmd.append(str(entry))
115
+ info("Running: " + " ".join(cmd))
116
+ subprocess.run(cmd, check=True)
117
+
118
+
119
+ def patch_macos_plist(app_path: Path, bundle_id: str, icon_base_name: str = "appicon") -> None:
120
+ info("Patching macOS Info.plist")
121
+ plist_path = app_path / "Contents" / "Info.plist"
122
+ if not plist_path.exists():
123
+ info(f"No Info.plist at {plist_path}, skipping patch")
124
+ return
125
+ with plist_path.open("rb") as f:
126
+ data = plistlib.load(f)
127
+ data["CFBundleIdentifier"] = bundle_id
128
+ data["CFBundleName"] = data.get("CFBundleName") or app_path.stem
129
+ data["CFBundleDisplayName"] = data.get("CFBundleDisplayName") or app_path.stem
130
+ data["CFBundleIconFile"] = icon_base_name
131
+ data["CFBundleIconName"] = icon_base_name
132
+ with plist_path.open("wb") as f:
133
+ plistlib.dump(data, f)
134
+
135
+ def make_dmg(app_path: Path, dmg_path: Path, volume_name: str) -> None:
136
+ info("Creating DMG")
137
+ staging = BUILD_DIR / "dmg_staging"
138
+ if staging.exists():
139
+ shutil.rmtree(staging)
140
+ (staging).mkdir(parents=True, exist_ok=True)
141
+ shutil.rmtree(staging / app_path.name, ignore_errors=True)
142
+ shutil.copytree(app_path, staging / app_path.name, symlinks=True)
143
+ try:
144
+ os.symlink("/Applications", staging / "Applications")
145
+ except FileExistsError:
146
+ pass
147
+ dmg_path.parent.mkdir(parents=True, exist_ok=True)
148
+ subprocess.run([
149
+ "hdiutil", "create", "-volname", volume_name,
150
+ "-srcfolder", str(staging),
151
+ "-format", "UDZO",
152
+ "-imagekey", "zlib-level=9",
153
+ str(dmg_path)
154
+ ], check=True)
155
+ shutil.rmtree(staging, ignore_errors=True)
156
+
157
+
158
+ def main() -> None:
159
+ parser = argparse.ArgumentParser()
160
+ parser.add_argument("--name", default="ChatMock")
161
+ parser.add_argument("--entry", default="gui.py")
162
+ parser.add_argument("--icon", default="icon.png")
163
+ parser.add_argument("--radius", type=float, default=0.22)
164
+ parser.add_argument("--square", action="store_true")
165
+ parser.add_argument("--dmg", action="store_true")
166
+ parser.add_argument("--dmg-only", action="store_true")
167
+ args = parser.parse_args()
168
+
169
+ ensure_dirs()
170
+ entry = ROOT / args.entry
171
+ icon_src = ROOT / args.icon
172
+ if args.dmg_only:
173
+ app_path = ROOT / "dist" / f"{args.name}.app"
174
+ if not app_path.exists():
175
+ raise SystemExit(f"App not found: {app_path}")
176
+ dmg = ROOT / "dist" / f"{args.name}.dmg"
177
+ make_dmg(app_path, dmg, args.name)
178
+ return
179
+ if not entry.exists():
180
+ raise SystemExit(f"Entry not found: {entry}")
181
+ if not icon_src.exists():
182
+ raise SystemExit(f"Icon PNG not found: {icon_src}")
183
+
184
+ os_name = platform.system().lower()
185
+ extra_data: list[tuple[Path, str]] = [
186
+ (ROOT / "prompt.md", "."),
187
+ (ROOT / "prompt_gpt5_codex.md", "."),
188
+ ]
189
+
190
+ bundle_icon: Path | None = None
191
+ rr = 0.0 if args.square else float(args.radius)
192
+ if os_name == "windows":
193
+ ico = ICONS_DIR / "appicon.ico"
194
+ make_windows_ico(icon_src, ico, rr)
195
+ bundle_icon = ico
196
+ extra_data.append((ico, "."))
197
+ elif os_name == "darwin":
198
+ icns = ICONS_DIR / "appicon.icns"
199
+ make_macos_icns(icon_src, icns, rr)
200
+ bundle_icon = icns
201
+ extra_data.append((icns, "."))
202
+ else:
203
+ png_copy = ICONS_DIR / "appicon.png"
204
+ if Image is not None:
205
+ square = load_icon_png(icon_src).resize((512, 512), Image.LANCZOS)
206
+ square = rounded(square, rr) if rr > 0 else square
207
+ square.save(png_copy)
208
+ else:
209
+ shutil.copy2(icon_src, png_copy)
210
+ extra_data.append((png_copy, "."))
211
+
212
+ run_pyinstaller(entry, args.name, bundle_icon, extra_data)
213
+ if os_name == "darwin":
214
+ app_path = ROOT / "dist" / f"{args.name}.app"
215
+ if app_path.exists():
216
+ bid = "com.chatmock.app"
217
+ patch_macos_plist(app_path, bundle_id=bid, icon_base_name="appicon")
218
+ if args.dmg:
219
+ dmg = ROOT / "dist" / f"{args.name}.dmg"
220
+ make_dmg(app_path, dmg, args.name)
221
+
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
build/lib/chatmock/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .app import create_app
4
+ from .cli import main
5
+ from .version import __version__
build/lib/chatmock/app.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from flask import Flask, jsonify
4
+ from flask_sock import Sock
5
+
6
+ from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
7
+ from .http import build_cors_headers
8
+ from .routes_openai import openai_bp
9
+ from .routes_ollama import ollama_bp
10
+ from .websocket_routes import register_websocket_routes
11
+
12
+
13
+ def create_app(
14
+ verbose: bool = False,
15
+ verbose_obfuscation: bool = False,
16
+ reasoning_effort: str = "medium",
17
+ reasoning_summary: str = "auto",
18
+ reasoning_compat: str = "think-tags",
19
+ fast_mode: bool = False,
20
+ debug_model: str | None = None,
21
+ expose_reasoning_models: bool = False,
22
+ default_web_search: bool = False,
23
+ ) -> Flask:
24
+ app = Flask(__name__)
25
+
26
+ app.config.update(
27
+ VERBOSE=bool(verbose),
28
+ VERBOSE_OBFUSCATION=bool(verbose_obfuscation),
29
+ REASONING_EFFORT=reasoning_effort,
30
+ REASONING_SUMMARY=reasoning_summary,
31
+ REASONING_COMPAT=reasoning_compat,
32
+ FAST_MODE=bool(fast_mode),
33
+ DEBUG_MODEL=debug_model,
34
+ BASE_INSTRUCTIONS=BASE_INSTRUCTIONS,
35
+ GPT5_CODEX_INSTRUCTIONS=GPT5_CODEX_INSTRUCTIONS,
36
+ EXPOSE_REASONING_MODELS=bool(expose_reasoning_models),
37
+ DEFAULT_WEB_SEARCH=bool(default_web_search),
38
+ )
39
+
40
+ @app.get("/")
41
+ @app.get("/health")
42
+ def health():
43
+ return jsonify({"status": "ok"})
44
+
45
+ @app.after_request
46
+ def _cors(resp):
47
+ for k, v in build_cors_headers().items():
48
+ resp.headers.setdefault(k, v)
49
+ return resp
50
+
51
+ app.register_blueprint(openai_bp)
52
+ app.register_blueprint(ollama_bp)
53
+ sock = Sock(app)
54
+ register_websocket_routes(sock)
55
+
56
+ return app
build/lib/chatmock/cli.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import webbrowser
9
+ from datetime import datetime
10
+
11
+ from .app import create_app
12
+ from .config import CLIENT_ID_DEFAULT
13
+ from .limits import RateLimitWindow, compute_reset_at, load_rate_limit_snapshot
14
+ from .oauth import OAuthHTTPServer, OAuthHandler, REQUIRED_PORT, URL_BASE
15
+ from .utils import eprint, get_home_dir, load_chatgpt_tokens, parse_jwt_claims, read_auth_file
16
+
17
+
18
+ _STATUS_LIMIT_BAR_SEGMENTS = 30
19
+ _STATUS_LIMIT_BAR_FILLED = "█"
20
+ _STATUS_LIMIT_BAR_EMPTY = "░"
21
+ _STATUS_LIMIT_BAR_PARTIAL = "▓"
22
+
23
+
24
+ def _clamp_percent(value: float) -> float:
25
+ try:
26
+ percent = float(value)
27
+ except Exception:
28
+ return 0.0
29
+ if percent != percent:
30
+ return 0.0
31
+ if percent < 0.0:
32
+ return 0.0
33
+ if percent > 100.0:
34
+ return 100.0
35
+ return percent
36
+
37
+
38
+ def _render_progress_bar(percent_used: float) -> str:
39
+ ratio = max(0.0, min(1.0, percent_used / 100.0))
40
+ filled_exact = ratio * _STATUS_LIMIT_BAR_SEGMENTS
41
+ filled = int(filled_exact)
42
+ partial = filled_exact - filled
43
+
44
+ has_partial = partial > 0.5
45
+ if has_partial:
46
+ filled += 1
47
+
48
+ filled = max(0, min(_STATUS_LIMIT_BAR_SEGMENTS, filled))
49
+ empty = _STATUS_LIMIT_BAR_SEGMENTS - filled
50
+
51
+ if has_partial and filled > 0:
52
+ bar = _STATUS_LIMIT_BAR_FILLED * (filled - 1) + _STATUS_LIMIT_BAR_PARTIAL + _STATUS_LIMIT_BAR_EMPTY * empty
53
+ else:
54
+ bar = _STATUS_LIMIT_BAR_FILLED * filled + _STATUS_LIMIT_BAR_EMPTY * empty
55
+
56
+ return f"[{bar}]"
57
+
58
+
59
+ def _get_usage_color(percent_used: float) -> str:
60
+ if percent_used >= 90:
61
+ return "\033[91m"
62
+ elif percent_used >= 75:
63
+ return "\033[93m"
64
+ elif percent_used >= 50:
65
+ return "\033[94m"
66
+ else:
67
+ return "\033[92m"
68
+
69
+
70
+ def _reset_color() -> str:
71
+ """ANSI reset color code"""
72
+ return "\033[0m"
73
+
74
+
75
+ def _format_window_duration(minutes: int | None) -> str | None:
76
+ if minutes is None:
77
+ return None
78
+ try:
79
+ total = int(minutes)
80
+ except Exception:
81
+ return None
82
+ if total <= 0:
83
+ return None
84
+ minutes = total
85
+ weeks, remainder = divmod(minutes, 7 * 24 * 60)
86
+ days, remainder = divmod(remainder, 24 * 60)
87
+ hours, remainder = divmod(remainder, 60)
88
+ parts = []
89
+ if weeks:
90
+ parts.append(f"{weeks} week" + ("s" if weeks != 1 else ""))
91
+ if days:
92
+ parts.append(f"{days} day" + ("s" if days != 1 else ""))
93
+ if hours:
94
+ parts.append(f"{hours} hour" + ("s" if hours != 1 else ""))
95
+ if remainder:
96
+ parts.append(f"{remainder} minute" + ("s" if remainder != 1 else ""))
97
+ if not parts:
98
+ parts.append(f"{minutes} minute" + ("s" if minutes != 1 else ""))
99
+ return " ".join(parts)
100
+
101
+
102
+ def _format_reset_duration(seconds: int | None) -> str | None:
103
+ if seconds is None:
104
+ return None
105
+ try:
106
+ value = int(seconds)
107
+ except Exception:
108
+ return None
109
+ if value < 0:
110
+ value = 0
111
+ days, remainder = divmod(value, 86400)
112
+ hours, remainder = divmod(remainder, 3600)
113
+ minutes, remainder = divmod(remainder, 60)
114
+ parts: list[str] = []
115
+ if days:
116
+ parts.append(f"{days}d")
117
+ if hours:
118
+ parts.append(f"{hours}h")
119
+ if minutes:
120
+ parts.append(f"{minutes}m")
121
+ if not parts and remainder:
122
+ parts.append("under 1m")
123
+ if not parts:
124
+ parts.append("0m")
125
+ return " ".join(parts)
126
+
127
+
128
+ def _format_local_datetime(dt: datetime) -> str:
129
+ local = dt.astimezone()
130
+ tz_name = local.tzname() or "local"
131
+ return f"{local.strftime('%b %d, %Y %H:%M')} {tz_name}"
132
+
133
+
134
+ def _print_usage_limits_block() -> None:
135
+ stored = load_rate_limit_snapshot()
136
+
137
+ print("📊 Usage Limits")
138
+
139
+ if stored is None:
140
+ print(" No usage data available yet. Send a request through ChatMock first.")
141
+ print()
142
+ return
143
+
144
+ update_time = _format_local_datetime(stored.captured_at)
145
+ print(f"Last updated: {update_time}")
146
+ print()
147
+
148
+ windows: list[tuple[str, str, RateLimitWindow]] = []
149
+ if stored.snapshot.primary is not None:
150
+ windows.append(("⚡", "5 hour limit", stored.snapshot.primary))
151
+ if stored.snapshot.secondary is not None:
152
+ windows.append(("📅", "Weekly limit", stored.snapshot.secondary))
153
+
154
+ if not windows:
155
+ print(" Usage data was captured but no limit windows were provided.")
156
+ print()
157
+ return
158
+
159
+ for i, (icon_label, desc, window) in enumerate(windows):
160
+ if i > 0:
161
+ print()
162
+
163
+ percent_used = _clamp_percent(window.used_percent)
164
+ remaining = max(0.0, 100.0 - percent_used)
165
+ color = _get_usage_color(percent_used)
166
+ reset = _reset_color()
167
+
168
+ progress = _render_progress_bar(percent_used)
169
+ usage_text = f"{percent_used:5.1f}% used"
170
+ remaining_text = f"{remaining:5.1f}% left"
171
+
172
+ print(f"{icon_label} {desc}")
173
+ print(f"{color}{progress}{reset} {color}{usage_text}{reset} | {remaining_text}")
174
+
175
+ reset_in = _format_reset_duration(window.resets_in_seconds)
176
+ reset_at = compute_reset_at(stored.captured_at, window)
177
+
178
+ if reset_in and reset_at:
179
+ reset_at_str = _format_local_datetime(reset_at)
180
+ print(f" ⏳ Resets in: {reset_in} at {reset_at_str}")
181
+ elif reset_in:
182
+ print(f" ⏳ Resets in: {reset_in}")
183
+ elif reset_at:
184
+ reset_at_str = _format_local_datetime(reset_at)
185
+ print(f" ⏳ Resets at: {reset_at_str}")
186
+
187
+ print()
188
+
189
+ def cmd_login(no_browser: bool, verbose: bool) -> int:
190
+ home_dir = get_home_dir()
191
+ client_id = CLIENT_ID_DEFAULT
192
+ if not client_id:
193
+ eprint("ERROR: No OAuth client id configured. Set CHATGPT_LOCAL_CLIENT_ID.")
194
+ return 1
195
+
196
+ try:
197
+ bind_host = os.getenv("CHATGPT_LOCAL_LOGIN_BIND", "127.0.0.1")
198
+ httpd = OAuthHTTPServer((bind_host, REQUIRED_PORT), OAuthHandler, home_dir=home_dir, client_id=client_id, verbose=verbose)
199
+ except OSError as e:
200
+ eprint(f"ERROR: {e}")
201
+ if e.errno == errno.EADDRINUSE:
202
+ return 13
203
+ return 1
204
+
205
+ auth_url = httpd.auth_url()
206
+ with httpd:
207
+ eprint(f"Starting local login server on {URL_BASE}")
208
+ if not no_browser:
209
+ try:
210
+ webbrowser.open(auth_url, new=1, autoraise=True)
211
+ except Exception as e:
212
+ eprint(f"Failed to open browser: {e}")
213
+ eprint(f"If your browser did not open, navigate to:\n{auth_url}")
214
+
215
+ def _stdin_paste_worker() -> None:
216
+ try:
217
+ eprint(
218
+ "If the browser can't reach this machine, paste the full redirect URL here and press Enter (or leave blank to keep waiting):"
219
+ )
220
+ line = sys.stdin.readline().strip()
221
+ if not line:
222
+ return
223
+ try:
224
+ from urllib.parse import urlparse, parse_qs
225
+
226
+ parsed = urlparse(line)
227
+ params = parse_qs(parsed.query)
228
+ code = (params.get("code") or [None])[0]
229
+ state = (params.get("state") or [None])[0]
230
+ if not code:
231
+ eprint("Input did not contain an auth code. Ignoring.")
232
+ return
233
+ if state and state != httpd.state:
234
+ eprint("State mismatch. Ignoring pasted URL for safety.")
235
+ return
236
+ eprint("Received redirect URL. Completing login without callback…")
237
+ bundle, _ = httpd.exchange_code(code)
238
+ if httpd.persist_auth(bundle):
239
+ httpd.exit_code = 0
240
+ eprint("Login successful. Tokens saved.")
241
+ else:
242
+ eprint("ERROR: Unable to persist auth file.")
243
+ httpd.shutdown()
244
+ except Exception as exc:
245
+ eprint(f"Failed to process pasted redirect URL: {exc}")
246
+ except Exception:
247
+ pass
248
+
249
+ try:
250
+ import threading
251
+
252
+ threading.Thread(target=_stdin_paste_worker, daemon=True).start()
253
+ except Exception:
254
+ pass
255
+ try:
256
+ httpd.serve_forever()
257
+ except KeyboardInterrupt:
258
+ eprint("\nKeyboard interrupt received, exiting.")
259
+ return httpd.exit_code
260
+
261
+
262
+ def cmd_serve(
263
+ host: str,
264
+ port: int,
265
+ verbose: bool,
266
+ verbose_obfuscation: bool,
267
+ reasoning_effort: str,
268
+ reasoning_summary: str,
269
+ reasoning_compat: str,
270
+ fast_mode: bool,
271
+ debug_model: str | None,
272
+ expose_reasoning_models: bool,
273
+ default_web_search: bool,
274
+ ) -> int:
275
+ app = create_app(
276
+ verbose=verbose,
277
+ verbose_obfuscation=verbose_obfuscation,
278
+ reasoning_effort=reasoning_effort,
279
+ reasoning_summary=reasoning_summary,
280
+ reasoning_compat=reasoning_compat,
281
+ fast_mode=fast_mode,
282
+ debug_model=debug_model,
283
+ expose_reasoning_models=expose_reasoning_models,
284
+ default_web_search=default_web_search,
285
+ )
286
+
287
+ app.run(host=host, use_reloader=False, port=port, threaded=True)
288
+ return 0
289
+
290
+
291
+ def main() -> None:
292
+ parser = argparse.ArgumentParser(description="ChatMock: login & OpenAI-compatible proxy")
293
+ sub = parser.add_subparsers(dest="command", required=True)
294
+
295
+ p_login = sub.add_parser("login", help="Authorize with ChatGPT and store tokens")
296
+ p_login.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically")
297
+ p_login.add_argument("--verbose", action="store_true", help="Enable verbose logging")
298
+
299
+ p_serve = sub.add_parser("serve", help="Run local OpenAI-compatible server")
300
+ p_serve.add_argument("--host", default="127.0.0.1")
301
+ p_serve.add_argument("--port", type=int, default=8000)
302
+ p_serve.add_argument("--verbose", action="store_true", help="Enable verbose logging")
303
+ p_serve.add_argument(
304
+ "--verbose-obfuscation",
305
+ action="store_true",
306
+ help="Also dump raw SSE/obfuscation events (in addition to --verbose request/response logs).",
307
+ )
308
+ p_serve.add_argument(
309
+ "--debug-model",
310
+ dest="debug_model",
311
+ default=os.getenv("CHATGPT_LOCAL_DEBUG_MODEL"),
312
+ help="Forcibly override requested 'model' with this value",
313
+ )
314
+ p_serve.add_argument(
315
+ "--fast-mode",
316
+ action=argparse.BooleanOptionalAction,
317
+ default=(os.getenv("CHATGPT_LOCAL_FAST_MODE") or "").strip().lower() in ("1", "true", "yes", "on"),
318
+ help="Enable GPT fast mode by default for supported models; request-level overrides still take precedence.",
319
+ )
320
+ p_serve.add_argument(
321
+ "--reasoning-effort",
322
+ choices=["none", "minimal", "low", "medium", "high", "xhigh"],
323
+ default=os.getenv("CHATGPT_LOCAL_REASONING_EFFORT", "medium").lower(),
324
+ help="Reasoning effort level for Responses API (default: medium)",
325
+ )
326
+ p_serve.add_argument(
327
+ "--reasoning-summary",
328
+ choices=["auto", "concise", "detailed", "none"],
329
+ default=os.getenv("CHATGPT_LOCAL_REASONING_SUMMARY", "auto").lower(),
330
+ help="Reasoning summary verbosity (default: auto)",
331
+ )
332
+ p_serve.add_argument(
333
+ "--reasoning-compat",
334
+ choices=["legacy", "o3", "think-tags", "current"],
335
+ default=os.getenv("CHATGPT_LOCAL_REASONING_COMPAT", "think-tags").lower(),
336
+ help=(
337
+ "Compatibility mode for exposing reasoning to clients (legacy|o3|think-tags). "
338
+ "'current' is accepted as an alias for 'legacy'"
339
+ ),
340
+ )
341
+ p_serve.add_argument(
342
+ "--expose-reasoning-models",
343
+ action="store_true",
344
+ default=(os.getenv("CHATGPT_LOCAL_EXPOSE_REASONING_MODELS") or "").strip().lower() in ("1", "true", "yes", "on"),
345
+ help=(
346
+ "Expose GPT-5 family reasoning effort variants (none|minimal|low|medium|high|xhigh where supported) "
347
+ "as separate models from /v1/models. This allows choosing effort via model selection in compatible UIs."
348
+ ),
349
+ )
350
+ p_serve.add_argument(
351
+ "--enable-web-search",
352
+ action=argparse.BooleanOptionalAction,
353
+ default=(os.getenv("CHATGPT_LOCAL_ENABLE_WEB_SEARCH") or "").strip().lower() in ("1", "true", "yes", "on"),
354
+ help=(
355
+ "Enable default web_search tool when a request omits responses_tools (off by default). "
356
+ "Also configurable via CHATGPT_LOCAL_ENABLE_WEB_SEARCH."
357
+ ),
358
+ )
359
+
360
+ p_info = sub.add_parser("info", help="Print current stored tokens and derived account id")
361
+ p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents")
362
+
363
+ args = parser.parse_args()
364
+
365
+ if args.command == "login":
366
+ sys.exit(cmd_login(no_browser=args.no_browser, verbose=args.verbose))
367
+ elif args.command == "serve":
368
+ sys.exit(
369
+ cmd_serve(
370
+ host=args.host,
371
+ port=args.port,
372
+ verbose=args.verbose,
373
+ verbose_obfuscation=args.verbose_obfuscation,
374
+ reasoning_effort=args.reasoning_effort,
375
+ reasoning_summary=args.reasoning_summary,
376
+ reasoning_compat=args.reasoning_compat,
377
+ fast_mode=args.fast_mode,
378
+ debug_model=args.debug_model,
379
+ expose_reasoning_models=args.expose_reasoning_models,
380
+ default_web_search=args.enable_web_search,
381
+ )
382
+ )
383
+ elif args.command == "info":
384
+ auth = read_auth_file()
385
+ if getattr(args, "json", False):
386
+ print(json.dumps(auth or {}, indent=2))
387
+ sys.exit(0)
388
+ access_token, account_id, id_token = load_chatgpt_tokens()
389
+ if not access_token or not id_token:
390
+ print("👤 Account")
391
+ print(" • Not signed in")
392
+ print(" • Run: python3 chatmock.py login")
393
+ print("")
394
+ _print_usage_limits_block()
395
+ sys.exit(0)
396
+
397
+ id_claims = parse_jwt_claims(id_token) or {}
398
+ access_claims = parse_jwt_claims(access_token) or {}
399
+
400
+ email = id_claims.get("email") or id_claims.get("preferred_username") or "<unknown>"
401
+ plan_raw = (access_claims.get("https://api.openai.com/auth") or {}).get("chatgpt_plan_type") or "unknown"
402
+ plan_map = {
403
+ "plus": "Plus",
404
+ "pro": "Pro",
405
+ "free": "Free",
406
+ "team": "Team",
407
+ "enterprise": "Enterprise",
408
+ }
409
+ plan = plan_map.get(str(plan_raw).lower(), str(plan_raw).title() if isinstance(plan_raw, str) else "Unknown")
410
+
411
+ print("👤 Account")
412
+ print(" • Signed in with ChatGPT")
413
+ print(f" • Login: {email}")
414
+ print(f" • Plan: {plan}")
415
+ if account_id:
416
+ print(f" • Account ID: {account_id}")
417
+ print("")
418
+ _print_usage_limits_block()
419
+ sys.exit(0)
420
+ else:
421
+ parser.error("Unknown command")
422
+
423
+
424
+ if __name__ == "__main__":
425
+ main()
build/lib/chatmock/config.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ CLIENT_ID_DEFAULT = os.getenv("CHATGPT_LOCAL_CLIENT_ID") or "app_EMoamEEZ73f0CkXaXp7hrann"
9
+ OAUTH_ISSUER_DEFAULT = os.getenv("CHATGPT_LOCAL_ISSUER") or "https://auth.openai.com"
10
+ OAUTH_TOKEN_URL = f"{OAUTH_ISSUER_DEFAULT}/oauth/token"
11
+
12
+ CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
13
+
14
+
15
+ def _read_prompt_text(filename: str) -> str | None:
16
+ candidates = [
17
+ Path(__file__).parent.parent / filename,
18
+ Path(__file__).parent / filename,
19
+ Path(getattr(sys, "_MEIPASS", "")) / filename if getattr(sys, "_MEIPASS", None) else None,
20
+ Path.cwd() / filename,
21
+ ]
22
+ for candidate in candidates:
23
+ if not candidate:
24
+ continue
25
+ try:
26
+ if candidate.exists():
27
+ content = candidate.read_text(encoding="utf-8")
28
+ if isinstance(content, str) and content.strip():
29
+ return content
30
+ except Exception:
31
+ continue
32
+ return None
33
+
34
+
35
+ def read_base_instructions() -> str:
36
+ content = _read_prompt_text("prompt.md")
37
+ if content is None:
38
+ raise FileNotFoundError("Failed to read prompt.md; expected adjacent to package or CWD.")
39
+ return content
40
+
41
+
42
+ def read_gpt5_codex_instructions(fallback: str) -> str:
43
+ content = _read_prompt_text("prompt_gpt5_codex.md")
44
+ return content if isinstance(content, str) and content.strip() else fallback
45
+
46
+
47
+ BASE_INSTRUCTIONS = read_base_instructions()
48
+ GPT5_CODEX_INSTRUCTIONS = read_gpt5_codex_instructions(BASE_INSTRUCTIONS)
build/lib/chatmock/fast_mode.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from .model_registry import normalize_model_name
7
+
8
+
9
+ PRIORITY_SUPPORTED_MODELS = frozenset(
10
+ (
11
+ "gpt-5.4",
12
+ "gpt-5.2",
13
+ "gpt-5.1",
14
+ "gpt-5",
15
+ "gpt-5.1-codex",
16
+ "gpt-5-codex",
17
+ )
18
+ )
19
+
20
+ _TRUE_STRINGS = {"1", "true", "yes", "on"}
21
+ _FALSE_STRINGS = {"0", "false", "no", "off"}
22
+
23
+
24
+ def parse_optional_bool(value: Any) -> bool | None:
25
+ if isinstance(value, bool):
26
+ return value
27
+ if isinstance(value, str):
28
+ normalized = value.strip().lower()
29
+ if normalized in _TRUE_STRINGS:
30
+ return True
31
+ if normalized in _FALSE_STRINGS:
32
+ return False
33
+ return None
34
+
35
+
36
+ def supports_priority_service_tier(model: str | None) -> bool:
37
+ return normalize_model_name(model) in PRIORITY_SUPPORTED_MODELS
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ServiceTierResolution:
42
+ service_tier: str | None
43
+ error_message: str | None = None
44
+ warning_message: str | None = None
45
+ used_server_default: bool = False
46
+
47
+
48
+ def resolve_service_tier(
49
+ model: str | None,
50
+ *,
51
+ request_fast_mode: Any = None,
52
+ request_service_tier: Any = None,
53
+ server_fast_mode: bool = False,
54
+ ) -> ServiceTierResolution:
55
+ explicit_fast_mode = parse_optional_bool(request_fast_mode)
56
+
57
+ tier: str | None = None
58
+ explicit_request = False
59
+ used_server_default = False
60
+
61
+ if explicit_fast_mode is not None:
62
+ tier = "priority" if explicit_fast_mode else None
63
+ explicit_request = True
64
+ elif isinstance(request_service_tier, str) and request_service_tier.strip():
65
+ tier = request_service_tier.strip().lower()
66
+ explicit_request = True
67
+ elif server_fast_mode:
68
+ tier = "priority"
69
+ used_server_default = True
70
+
71
+ if tier == "priority" and not supports_priority_service_tier(model):
72
+ normalized = normalize_model_name(model)
73
+ message = (
74
+ f"Fast mode is not supported for model '{normalized}'. "
75
+ "Use a supported GPT-5 priority-processing model or disable fast mode for this request."
76
+ )
77
+ if explicit_request:
78
+ return ServiceTierResolution(
79
+ service_tier=None,
80
+ error_message=message,
81
+ used_server_default=used_server_default,
82
+ )
83
+ return ServiceTierResolution(
84
+ service_tier=None,
85
+ warning_message=message,
86
+ used_server_default=used_server_default,
87
+ )
88
+
89
+ return ServiceTierResolution(
90
+ service_tier=tier,
91
+ used_server_default=used_server_default,
92
+ )
build/lib/chatmock/http.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from flask import Response, jsonify, request
4
+
5
+
6
+ def build_cors_headers() -> dict:
7
+ origin = request.headers.get("Origin", "*")
8
+ req_headers = request.headers.get("Access-Control-Request-Headers")
9
+ allow_headers = req_headers if req_headers else "Authorization, Content-Type, Accept"
10
+ return {
11
+ "Access-Control-Allow-Origin": origin,
12
+ "Access-Control-Allow-Methods": "POST, GET, OPTIONS",
13
+ "Access-Control-Allow-Headers": allow_headers,
14
+ "Access-Control-Max-Age": "86400",
15
+ }
16
+
17
+
18
+ def json_error(message: str, status: int = 400) -> Response:
19
+ resp = jsonify({"error": {"message": message}})
20
+ response: Response = Response(response=resp.response, status=status, mimetype="application/json")
21
+ for k, v in build_cors_headers().items():
22
+ response.headers.setdefault(k, v)
23
+ return response
24
+
build/lib/chatmock/limits.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Any, Mapping, Optional
8
+
9
+ from .utils import get_home_dir
10
+
11
+ _PRIMARY_USED = "x-codex-primary-used-percent"
12
+ _PRIMARY_WINDOW = "x-codex-primary-window-minutes"
13
+ _PRIMARY_RESET = "x-codex-primary-reset-after-seconds"
14
+ _SECONDARY_USED = "x-codex-secondary-used-percent"
15
+ _SECONDARY_WINDOW = "x-codex-secondary-window-minutes"
16
+ _SECONDARY_RESET = "x-codex-secondary-reset-after-seconds"
17
+
18
+ _LIMITS_FILENAME = "usage_limits.json"
19
+
20
+
21
+ @dataclass
22
+ class RateLimitWindow:
23
+ used_percent: float
24
+ window_minutes: Optional[int]
25
+ resets_in_seconds: Optional[int]
26
+
27
+
28
+ @dataclass
29
+ class RateLimitSnapshot:
30
+ primary: Optional[RateLimitWindow]
31
+ secondary: Optional[RateLimitWindow]
32
+
33
+
34
+ @dataclass
35
+ class StoredRateLimitSnapshot:
36
+ captured_at: datetime
37
+ snapshot: RateLimitSnapshot
38
+
39
+
40
+ def _parse_float(value: Any) -> Optional[float]:
41
+ try:
42
+ if value is None:
43
+ return None
44
+ if isinstance(value, (int, float)):
45
+ return float(value)
46
+ value_str = str(value).strip()
47
+ if not value_str:
48
+ return None
49
+ parsed = float(value_str)
50
+ if not (parsed == parsed and parsed not in (float("inf"), float("-inf"))):
51
+ return None
52
+ return parsed
53
+ except Exception:
54
+ return None
55
+
56
+
57
+ def _parse_int(value: Any) -> Optional[int]:
58
+ try:
59
+ if value is None:
60
+ return None
61
+ if isinstance(value, bool):
62
+ return None
63
+ if isinstance(value, int):
64
+ return value
65
+ value_str = str(value).strip()
66
+ if not value_str:
67
+ return None
68
+ return int(value_str)
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def _parse_window(headers: Mapping[str, Any], used_key: str, window_key: str, reset_key: str) -> Optional[RateLimitWindow]:
74
+ used_percent = _parse_float(headers.get(used_key))
75
+ if used_percent is None:
76
+ return None
77
+ window_minutes = _parse_int(headers.get(window_key))
78
+ resets_in_seconds = _parse_int(headers.get(reset_key))
79
+ return RateLimitWindow(used_percent=used_percent, window_minutes=window_minutes, resets_in_seconds=resets_in_seconds)
80
+
81
+
82
+ def parse_rate_limit_headers(headers: Mapping[str, Any]) -> Optional[RateLimitSnapshot]:
83
+ try:
84
+ primary = _parse_window(headers, _PRIMARY_USED, _PRIMARY_WINDOW, _PRIMARY_RESET)
85
+ secondary = _parse_window(headers, _SECONDARY_USED, _SECONDARY_WINDOW, _SECONDARY_RESET)
86
+ if primary is None and secondary is None:
87
+ return None
88
+ return RateLimitSnapshot(primary=primary, secondary=secondary)
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def _limits_path() -> str:
94
+ home = get_home_dir()
95
+ return os.path.join(home, _LIMITS_FILENAME)
96
+
97
+
98
+ def store_rate_limit_snapshot(snapshot: RateLimitSnapshot, captured_at: Optional[datetime] = None) -> None:
99
+ captured = captured_at or datetime.now(timezone.utc)
100
+ try:
101
+ home = get_home_dir()
102
+ os.makedirs(home, exist_ok=True)
103
+ payload: dict[str, Any] = {
104
+ "captured_at": captured.isoformat(),
105
+ }
106
+ if snapshot.primary:
107
+ payload["primary"] = {
108
+ "used_percent": snapshot.primary.used_percent,
109
+ "window_minutes": snapshot.primary.window_minutes,
110
+ "resets_in_seconds": snapshot.primary.resets_in_seconds,
111
+ }
112
+ if snapshot.secondary:
113
+ payload["secondary"] = {
114
+ "used_percent": snapshot.secondary.used_percent,
115
+ "window_minutes": snapshot.secondary.window_minutes,
116
+ "resets_in_seconds": snapshot.secondary.resets_in_seconds,
117
+ }
118
+ with open(_limits_path(), "w", encoding="utf-8") as fp:
119
+ if hasattr(os, "fchmod"):
120
+ try:
121
+ os.fchmod(fp.fileno(), 0o600)
122
+ except OSError:
123
+ pass
124
+ json.dump(payload, fp, indent=2)
125
+ except Exception:
126
+ # Silently ignore persistence errors.
127
+ pass
128
+
129
+
130
+ def load_rate_limit_snapshot() -> Optional[StoredRateLimitSnapshot]:
131
+ try:
132
+ with open(_limits_path(), "r", encoding="utf-8") as fp:
133
+ raw = json.load(fp)
134
+ except FileNotFoundError:
135
+ return None
136
+ except Exception:
137
+ return None
138
+
139
+ captured_raw = raw.get("captured_at")
140
+ captured_at = _parse_datetime(captured_raw)
141
+ if captured_at is None:
142
+ return None
143
+
144
+ snapshot = RateLimitSnapshot(
145
+ primary=_dict_to_window(raw.get("primary")),
146
+ secondary=_dict_to_window(raw.get("secondary")),
147
+ )
148
+ if snapshot.primary is None and snapshot.secondary is None:
149
+ return None
150
+ return StoredRateLimitSnapshot(captured_at=captured_at, snapshot=snapshot)
151
+
152
+
153
+ def _parse_datetime(value: Any) -> Optional[datetime]:
154
+ if not isinstance(value, str):
155
+ return None
156
+ text = value.strip()
157
+ if not text:
158
+ return None
159
+ if text.endswith("Z"):
160
+ text = text[:-1] + "+00:00"
161
+ try:
162
+ dt = datetime.fromisoformat(text)
163
+ if dt.tzinfo is None:
164
+ return dt.replace(tzinfo=timezone.utc)
165
+ return dt
166
+ except ValueError:
167
+ return None
168
+
169
+
170
+ def _dict_to_window(value: Any) -> Optional[RateLimitWindow]:
171
+ if not isinstance(value, dict):
172
+ return None
173
+ used = _parse_float(value.get("used_percent"))
174
+ if used is None:
175
+ return None
176
+ window = _parse_int(value.get("window_minutes"))
177
+ resets = _parse_int(value.get("resets_in_seconds"))
178
+ return RateLimitWindow(used_percent=used, window_minutes=window, resets_in_seconds=resets)
179
+
180
+
181
+ def record_rate_limits_from_response(response: Any) -> None:
182
+ if response is None:
183
+ return
184
+ headers = getattr(response, "headers", None)
185
+ if headers is None:
186
+ return
187
+ snapshot = parse_rate_limit_headers(headers)
188
+ if snapshot is None:
189
+ return
190
+ store_rate_limit_snapshot(snapshot)
191
+
192
+
193
+ def compute_reset_at(captured_at: datetime, window: RateLimitWindow) -> Optional[datetime]:
194
+ if window.resets_in_seconds is None:
195
+ return None
196
+ try:
197
+ return captured_at + timedelta(seconds=int(window.resets_in_seconds))
198
+ except Exception:
199
+ return None
200
+
build/lib/chatmock/model_registry.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable
5
+
6
+
7
+ ALL_REASONING_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh")
8
+ DEFAULT_REASONING_EFFORTS = frozenset(ALL_REASONING_EFFORTS)
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ModelSpec:
13
+ public_id: str
14
+ upstream_id: str
15
+ aliases: tuple[str, ...]
16
+ allowed_efforts: frozenset[str]
17
+ variant_efforts: tuple[str, ...]
18
+ uses_codex_instructions: bool = False
19
+
20
+
21
+ _MODEL_SPECS = (
22
+ ModelSpec(
23
+ public_id="gpt-5",
24
+ upstream_id="gpt-5",
25
+ aliases=("gpt5", "gpt-5-latest"),
26
+ allowed_efforts=DEFAULT_REASONING_EFFORTS,
27
+ variant_efforts=("high", "medium", "low", "minimal"),
28
+ ),
29
+ ModelSpec(
30
+ public_id="gpt-5.1",
31
+ upstream_id="gpt-5.1",
32
+ aliases=(),
33
+ allowed_efforts=frozenset(("low", "medium", "high")),
34
+ variant_efforts=("high", "medium", "low"),
35
+ ),
36
+ ModelSpec(
37
+ public_id="gpt-5.2",
38
+ upstream_id="gpt-5.2",
39
+ aliases=("gpt5.2", "gpt-5.2-latest"),
40
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
41
+ variant_efforts=("xhigh", "high", "medium", "low"),
42
+ ),
43
+ ModelSpec(
44
+ public_id="gpt-5.4",
45
+ upstream_id="gpt-5.4",
46
+ aliases=("gpt5.4", "gpt-5.4-latest"),
47
+ allowed_efforts=frozenset(("none", "low", "medium", "high", "xhigh")),
48
+ variant_efforts=("xhigh", "high", "medium", "low", "none"),
49
+ ),
50
+ ModelSpec(
51
+ public_id="gpt-5.4-mini",
52
+ upstream_id="gpt-5.4-mini",
53
+ aliases=("gpt5.4-mini", "gpt-5.4-mini-latest"),
54
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
55
+ variant_efforts=("xhigh", "high", "medium", "low"),
56
+ ),
57
+ ModelSpec(
58
+ public_id="gpt-5.3-codex",
59
+ upstream_id="gpt-5.3-codex",
60
+ aliases=("gpt5.3-codex", "gpt-5.3-codex-latest"),
61
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
62
+ variant_efforts=("xhigh", "high", "medium", "low"),
63
+ uses_codex_instructions=True,
64
+ ),
65
+ ModelSpec(
66
+ public_id="gpt-5.3-codex-spark",
67
+ upstream_id="gpt-5.3-codex-spark",
68
+ aliases=("gpt5.3-codex-spark", "gpt-5.3-codex-spark-latest"),
69
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
70
+ variant_efforts=("xhigh", "high", "medium", "low"),
71
+ uses_codex_instructions=True,
72
+ ),
73
+ ModelSpec(
74
+ public_id="gpt-5-codex",
75
+ upstream_id="gpt-5-codex",
76
+ aliases=("gpt5-codex", "gpt-5-codex-latest"),
77
+ allowed_efforts=DEFAULT_REASONING_EFFORTS,
78
+ variant_efforts=("high", "medium", "low"),
79
+ uses_codex_instructions=True,
80
+ ),
81
+ ModelSpec(
82
+ public_id="gpt-5.2-codex",
83
+ upstream_id="gpt-5.2-codex",
84
+ aliases=("gpt5.2-codex", "gpt-5.2-codex-latest"),
85
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
86
+ variant_efforts=("xhigh", "high", "medium", "low"),
87
+ uses_codex_instructions=True,
88
+ ),
89
+ ModelSpec(
90
+ public_id="gpt-5.1-codex",
91
+ upstream_id="gpt-5.1-codex",
92
+ aliases=(),
93
+ allowed_efforts=frozenset(("low", "medium", "high")),
94
+ variant_efforts=("high", "medium", "low"),
95
+ uses_codex_instructions=True,
96
+ ),
97
+ ModelSpec(
98
+ public_id="gpt-5.1-codex-max",
99
+ upstream_id="gpt-5.1-codex-max",
100
+ aliases=(),
101
+ allowed_efforts=frozenset(("low", "medium", "high", "xhigh")),
102
+ variant_efforts=("xhigh", "high", "medium", "low"),
103
+ uses_codex_instructions=True,
104
+ ),
105
+ ModelSpec(
106
+ public_id="gpt-5.1-codex-mini",
107
+ upstream_id="gpt-5.1-codex-mini",
108
+ aliases=(),
109
+ allowed_efforts=frozenset(("low", "medium", "high")),
110
+ variant_efforts=(),
111
+ uses_codex_instructions=True,
112
+ ),
113
+ ModelSpec(
114
+ public_id="codex-mini",
115
+ upstream_id="codex-mini-latest",
116
+ aliases=("codex", "codex-mini-latest"),
117
+ allowed_efforts=DEFAULT_REASONING_EFFORTS,
118
+ variant_efforts=(),
119
+ uses_codex_instructions=True,
120
+ ),
121
+ )
122
+
123
+ _SPECS_BY_UPSTREAM = {spec.upstream_id: spec for spec in _MODEL_SPECS}
124
+ _ALIASES = {}
125
+ for _spec in _MODEL_SPECS:
126
+ _ALIASES[_spec.public_id] = _spec.upstream_id
127
+ for _alias in _spec.aliases:
128
+ _ALIASES[_alias] = _spec.upstream_id
129
+
130
+
131
+ def _strip_model_name(model: str | None) -> tuple[str, str | None]:
132
+ if not isinstance(model, str):
133
+ return "", None
134
+ value = model.strip().lower()
135
+ if not value:
136
+ return "", None
137
+ if ":" in value:
138
+ base, maybe_effort = value.rsplit(":", 1)
139
+ if maybe_effort in DEFAULT_REASONING_EFFORTS:
140
+ return base, maybe_effort
141
+ for separator in ("-", "_"):
142
+ for effort in ALL_REASONING_EFFORTS:
143
+ suffix = f"{separator}{effort}"
144
+ if value.endswith(suffix):
145
+ return value[: -len(suffix)], effort
146
+ return value, None
147
+
148
+
149
+ def model_spec_for_name(model: str | None) -> ModelSpec | None:
150
+ base, _ = _strip_model_name(model)
151
+ upstream_id = _ALIASES.get(base)
152
+ if not upstream_id:
153
+ return None
154
+ return _SPECS_BY_UPSTREAM.get(upstream_id)
155
+
156
+
157
+ def normalize_model_name(model: str | None, debug_model: str | None = None) -> str:
158
+ if isinstance(debug_model, str) and debug_model.strip():
159
+ return debug_model.strip()
160
+ spec = model_spec_for_name(model)
161
+ if spec is not None:
162
+ return spec.upstream_id
163
+ base, _ = _strip_model_name(model)
164
+ return base or "gpt-5.4"
165
+
166
+
167
+ def uses_codex_instructions(model: str | None) -> bool:
168
+ spec = model_spec_for_name(model)
169
+ if spec is not None:
170
+ return spec.uses_codex_instructions
171
+ return "codex" in ((model or "").strip().lower())
172
+
173
+
174
+ def allowed_efforts_for_model(model: str | None) -> frozenset[str]:
175
+ spec = model_spec_for_name(model)
176
+ if spec is not None:
177
+ return spec.allowed_efforts
178
+ return DEFAULT_REASONING_EFFORTS
179
+
180
+
181
+ def extract_reasoning_from_model_name(model: str | None) -> dict[str, str] | None:
182
+ _, effort = _strip_model_name(model)
183
+ if not effort:
184
+ return None
185
+ return {"effort": effort}
186
+
187
+
188
+ def list_public_models(expose_reasoning_models: bool = False) -> list[str]:
189
+ model_ids: list[str] = []
190
+ for spec in _MODEL_SPECS:
191
+ model_ids.append(spec.public_id)
192
+ if expose_reasoning_models:
193
+ model_ids.extend(f"{spec.public_id}-{effort}" for effort in spec.variant_efforts)
194
+ return model_ids
195
+
196
+
197
+ def iter_public_models() -> Iterable[ModelSpec]:
198
+ return _MODEL_SPECS
build/lib/chatmock/models.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class TokenData:
9
+ id_token: str
10
+ access_token: str
11
+ refresh_token: str
12
+ account_id: str
13
+
14
+
15
+ @dataclass
16
+ class AuthBundle:
17
+ api_key: Optional[str]
18
+ token_data: TokenData
19
+ last_refresh: str
20
+
21
+
22
+ @dataclass
23
+ class PkceCodes:
24
+ code_verifier: str
25
+ code_challenge: str
26
+
build/lib/chatmock/oauth.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import ssl
5
+ import http.server
6
+ import json
7
+ import secrets
8
+ import threading
9
+ import time
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any, Dict, Tuple
13
+
14
+ import certifi
15
+
16
+ from .config import OAUTH_ISSUER_DEFAULT
17
+ from .models import AuthBundle, PkceCodes, TokenData
18
+ from .utils import eprint, generate_pkce, parse_jwt_claims, write_auth_file
19
+
20
+
21
+ REQUIRED_PORT = 1455
22
+ URL_BASE = f"http://localhost:{REQUIRED_PORT}"
23
+ DEFAULT_ISSUER = OAUTH_ISSUER_DEFAULT
24
+
25
+
26
+ LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
27
+ <html lang=\"en\">
28
+ <head>
29
+ <meta charset=\"utf-8\" />
30
+ <title>Login successful</title>
31
+ </head>
32
+ <body>
33
+ <div style=\"max-width: 640px; margin: 80px auto; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;\">
34
+ <h1>Login successful</h1>
35
+ <p>You can now close this window and return to the terminal and run <code>python3 chatmock.py serve</code> to start the server.</p>
36
+ </div>
37
+ </body>
38
+ </html>
39
+ """
40
+
41
+ _SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
42
+
43
+ class OAuthHTTPServer(http.server.HTTPServer):
44
+ def __init__(
45
+ self,
46
+ server_address: tuple[str, int],
47
+ request_handler_class: type[http.server.BaseHTTPRequestHandler],
48
+ *,
49
+ home_dir: str,
50
+ client_id: str,
51
+ verbose: bool = False,
52
+ ) -> None:
53
+ super().__init__(server_address, request_handler_class, bind_and_activate=True)
54
+ self.exit_code = 1
55
+ self.home_dir = home_dir
56
+ self.verbose = verbose
57
+ self.issuer = DEFAULT_ISSUER
58
+ self.token_endpoint = f"{self.issuer}/oauth/token"
59
+ self.client_id = client_id
60
+ port = server_address[1]
61
+ self.redirect_uri = f"http://localhost:{port}/auth/callback"
62
+ self.pkce = generate_pkce()
63
+ self.state = secrets.token_hex(32)
64
+
65
+ def auth_url(self) -> str:
66
+ params = {
67
+ "response_type": "code",
68
+ "client_id": self.client_id,
69
+ "redirect_uri": self.redirect_uri,
70
+ "scope": "openid profile email offline_access",
71
+ "code_challenge": self.pkce.code_challenge,
72
+ "code_challenge_method": "S256",
73
+ "id_token_add_organizations": "true",
74
+ "codex_cli_simplified_flow": "true",
75
+ "state": self.state,
76
+ }
77
+ return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
78
+
79
+ def exchange_code(self, code: str) -> tuple[AuthBundle, str]:
80
+ data = urllib.parse.urlencode(
81
+ {
82
+ "grant_type": "authorization_code",
83
+ "code": code,
84
+ "redirect_uri": self.redirect_uri,
85
+ "client_id": self.client_id,
86
+ "code_verifier": self.pkce.code_verifier,
87
+ }
88
+ ).encode()
89
+
90
+ with urllib.request.urlopen(
91
+ urllib.request.Request(
92
+ self.token_endpoint,
93
+ data=data,
94
+ method="POST",
95
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
96
+ ),
97
+ context=_SSL_CONTEXT,
98
+ ) as resp:
99
+ payload = json.loads(resp.read().decode())
100
+
101
+ id_token = payload.get("id_token", "")
102
+ access_token = payload.get("access_token", "")
103
+ refresh_token = payload.get("refresh_token", "")
104
+
105
+ id_token_claims = parse_jwt_claims(id_token)
106
+ access_token_claims = parse_jwt_claims(access_token)
107
+
108
+ auth_claims = (id_token_claims or {}).get("https://api.openai.com/auth", {})
109
+ chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
110
+
111
+ token_data = TokenData(
112
+ id_token=id_token,
113
+ access_token=access_token,
114
+ refresh_token=refresh_token,
115
+ account_id=chatgpt_account_id,
116
+ )
117
+
118
+ api_key, success_url = self.maybe_obtain_api_key(
119
+ id_token_claims or {}, access_token_claims or {}, token_data
120
+ )
121
+
122
+ last_refresh_str = (
123
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
124
+ )
125
+ bundle = AuthBundle(api_key=api_key, token_data=token_data, last_refresh=last_refresh_str)
126
+ return bundle, success_url or f"{URL_BASE}/success"
127
+
128
+ def maybe_obtain_api_key(
129
+ self,
130
+ token_claims: Dict[str, Any],
131
+ access_claims: Dict[str, Any],
132
+ token_data: TokenData,
133
+ ) -> tuple[str | None, str | None]:
134
+ org_id = token_claims.get("organization_id")
135
+ project_id = token_claims.get("project_id")
136
+ if not org_id or not project_id:
137
+ query = {
138
+ "id_token": token_data.id_token,
139
+ "needs_setup": "false",
140
+ "org_id": org_id or "",
141
+ "project_id": project_id or "",
142
+ "plan_type": access_claims.get("chatgpt_plan_type"),
143
+ "platform_url": "https://platform.openai.com",
144
+ }
145
+ return None, f"{URL_BASE}/success?{urllib.parse.urlencode(query)}"
146
+
147
+ today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
148
+ exchange_data = urllib.parse.urlencode(
149
+ {
150
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
151
+ "client_id": self.client_id,
152
+ "requested_token": "openai-api-key",
153
+ "subject_token": token_data.id_token,
154
+ "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
155
+ "name": f"ChatMock [auto-generated] ({today})",
156
+ }
157
+ ).encode()
158
+
159
+ with urllib.request.urlopen(
160
+ urllib.request.Request(
161
+ self.token_endpoint,
162
+ data=exchange_data,
163
+ method="POST",
164
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
165
+ ),
166
+ context=_SSL_CONTEXT,
167
+ ) as resp:
168
+ exchange_payload = json.loads(resp.read().decode())
169
+ exchanged_access_token = exchange_payload.get("access_token")
170
+
171
+ chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
172
+ success_url_query = {
173
+ "id_token": token_data.id_token,
174
+ "access_token": token_data.access_token,
175
+ "refresh_token": token_data.refresh_token,
176
+ "exchanged_access_token": exchanged_access_token,
177
+ "org_id": org_id,
178
+ "project_id": project_id,
179
+ "plan_type": chatgpt_plan_type,
180
+ "platform_url": "https://platform.openai.com",
181
+ }
182
+ success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
183
+ return exchanged_access_token, success_url
184
+
185
+ def persist_auth(self, bundle: AuthBundle) -> bool:
186
+ auth_json_contents = {
187
+ "OPENAI_API_KEY": bundle.api_key,
188
+ "tokens": {
189
+ "id_token": bundle.token_data.id_token,
190
+ "access_token": bundle.token_data.access_token,
191
+ "refresh_token": bundle.token_data.refresh_token,
192
+ "account_id": bundle.token_data.account_id,
193
+ },
194
+ "last_refresh": bundle.last_refresh,
195
+ }
196
+ return write_auth_file(auth_json_contents)
197
+
198
+
199
+ class OAuthHandler(http.server.BaseHTTPRequestHandler):
200
+ server: "OAuthHTTPServer"
201
+
202
+ def do_GET(self) -> None:
203
+ path = urllib.parse.urlparse(self.path).path
204
+ if path == "/success":
205
+ self._send_html(LOGIN_SUCCESS_HTML)
206
+ try:
207
+ self.wfile.flush()
208
+ except Exception as e:
209
+ eprint(f"Failed to flush response: {e}")
210
+ self._shutdown_after_delay(2.0)
211
+ return
212
+
213
+ if path != "/auth/callback":
214
+ self.send_error(404, "Not Found")
215
+ self._shutdown()
216
+ return
217
+
218
+ query = urllib.parse.urlparse(self.path).query
219
+ params = urllib.parse.parse_qs(query)
220
+
221
+ code = params.get("code", [None])[0]
222
+ if not code:
223
+ self.send_error(400, "Missing auth code")
224
+ self._shutdown()
225
+ return
226
+
227
+ try:
228
+ auth_bundle, success_url = self._exchange_code(code)
229
+ except Exception as exc:
230
+ self.send_error(500, f"Token exchange failed: {exc}")
231
+ self._shutdown()
232
+ return
233
+
234
+ auth_json_contents = {
235
+ "OPENAI_API_KEY": auth_bundle.api_key,
236
+ "tokens": {
237
+ "id_token": auth_bundle.token_data.id_token,
238
+ "access_token": auth_bundle.token_data.access_token,
239
+ "refresh_token": auth_bundle.token_data.refresh_token,
240
+ "account_id": auth_bundle.token_data.account_id,
241
+ },
242
+ "last_refresh": auth_bundle.last_refresh,
243
+ }
244
+ if write_auth_file(auth_json_contents):
245
+ self.server.exit_code = 0
246
+ self._send_html(LOGIN_SUCCESS_HTML)
247
+ else:
248
+ self.send_error(500, "Unable to persist auth file")
249
+ self._shutdown_after_delay(2.0)
250
+
251
+ def do_POST(self) -> None:
252
+ self.send_error(404, "Not Found")
253
+ self._shutdown()
254
+
255
+ def log_message(self, fmt: str, *args):
256
+ if getattr(self.server, "verbose", False):
257
+ super().log_message(fmt, *args)
258
+
259
+ def _send_redirect(self, url: str) -> None:
260
+ self.send_response(302)
261
+ self.send_header("Location", url)
262
+ self.end_headers()
263
+
264
+ def _send_html(self, body: str) -> None:
265
+ encoded = body.encode()
266
+ self.send_response(200)
267
+ self.send_header("Content-Type", "text/html; charset=utf-8")
268
+ self.send_header("Content-Length", str(len(encoded)))
269
+ self.end_headers()
270
+ self.wfile.write(encoded)
271
+
272
+ def _shutdown(self) -> None:
273
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
274
+
275
+ def _shutdown_after_delay(self, seconds: float = 2.0) -> None:
276
+ def _later():
277
+ try:
278
+ time.sleep(seconds)
279
+ finally:
280
+ self._shutdown()
281
+
282
+ threading.Thread(target=_later, daemon=True).start()
283
+
284
+ def _exchange_code(self, code: str) -> Tuple[AuthBundle, str]:
285
+ return self.server.exchange_code(code)
286
+
287
+ def _maybe_obtain_api_key(
288
+ self,
289
+ token_claims: Dict[str, Any],
290
+ access_claims: Dict[str, Any],
291
+ token_data: TokenData,
292
+ ) -> Tuple[str | None, str | None]:
293
+ org_id = token_claims.get("organization_id")
294
+ project_id = token_claims.get("project_id")
295
+ if not org_id or not project_id:
296
+ query = {
297
+ "id_token": token_data.id_token,
298
+ "needs_setup": "false",
299
+ "org_id": org_id or "",
300
+ "project_id": project_id or "",
301
+ "plan_type": access_claims.get("chatgpt_plan_type"),
302
+ "platform_url": "https://platform.openai.com",
303
+ }
304
+ return None, f"{URL_BASE}/success?{urllib.parse.urlencode(query)}"
305
+
306
+ today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
307
+ exchange_data = urllib.parse.urlencode(
308
+ {
309
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
310
+ "client_id": self.server.client_id,
311
+ "requested_token": "openai-api-key",
312
+ "subject_token": token_data.id_token,
313
+ "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
314
+ "name": f"ChatMock [auto-generated] ({today})",
315
+ }
316
+ ).encode()
317
+
318
+ with urllib.request.urlopen(
319
+ urllib.request.Request(
320
+ self.server.token_endpoint,
321
+ data=exchange_data,
322
+ method="POST",
323
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
324
+ ),
325
+ context=_SSL_CONTEXT,
326
+ ) as resp:
327
+ exchange_payload = json.loads(resp.read().decode())
328
+ exchanged_access_token = exchange_payload.get("access_token")
329
+
330
+ chatgpt_plan_type = access_claims.get("chatgpt_plan_type")
331
+ success_url_query = {
332
+ "id_token": token_data.id_token,
333
+ "needs_setup": "false",
334
+ "org_id": org_id,
335
+ "project_id": project_id,
336
+ "plan_type": chatgpt_plan_type,
337
+ "platform_url": "https://platform.openai.com",
338
+ }
339
+ success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_url_query)}"
340
+ return exchanged_access_token, success_url
build/lib/chatmock/prompt.md ADDED
@@ -0,0 +1 @@
 
 
1
+ ../prompt.md
build/lib/chatmock/prompt_gpt5_codex.md ADDED
@@ -0,0 +1 @@
 
 
1
+ ../prompt_gpt5_codex.md
build/lib/chatmock/reasoning.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ from .model_registry import DEFAULT_REASONING_EFFORTS, allowed_efforts_for_model, extract_reasoning_from_model_name
6
+
7
+
8
+ def build_reasoning_param(
9
+ base_effort: str = "medium",
10
+ base_summary: str = "auto",
11
+ overrides: Dict[str, Any] | None = None,
12
+ *,
13
+ allowed_efforts: frozenset[str] | None = None,
14
+ ) -> Dict[str, Any]:
15
+ effort = (base_effort or "").strip().lower()
16
+ summary = (base_summary or "").strip().lower()
17
+
18
+ valid_efforts = allowed_efforts or DEFAULT_REASONING_EFFORTS
19
+ valid_summaries = {"auto", "concise", "detailed", "none"}
20
+
21
+ if isinstance(overrides, dict):
22
+ o_eff = str(overrides.get("effort", "")).strip().lower()
23
+ o_sum = str(overrides.get("summary", "")).strip().lower()
24
+ if o_eff in valid_efforts and o_eff:
25
+ effort = o_eff
26
+ if o_sum in valid_summaries and o_sum:
27
+ summary = o_sum
28
+ if effort not in valid_efforts:
29
+ effort = "medium"
30
+ if summary not in valid_summaries:
31
+ summary = "auto"
32
+
33
+ reasoning: Dict[str, Any] = {"effort": effort}
34
+ if summary != "none":
35
+ reasoning["summary"] = summary
36
+ return reasoning
37
+
38
+
39
+ def apply_reasoning_to_message(
40
+ message: Dict[str, Any],
41
+ reasoning_summary_text: str,
42
+ reasoning_full_text: str,
43
+ compat: str,
44
+ ) -> Dict[str, Any]:
45
+ try:
46
+ compat = (compat or "think-tags").strip().lower()
47
+ except Exception:
48
+ compat = "think-tags"
49
+
50
+ if compat == "o3":
51
+ rtxt_parts: list[str] = []
52
+ if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
53
+ rtxt_parts.append(reasoning_summary_text)
54
+ if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
55
+ rtxt_parts.append(reasoning_full_text)
56
+ rtxt = "\n\n".join([p for p in rtxt_parts if p])
57
+ if rtxt:
58
+ message["reasoning"] = {"content": [{"type": "text", "text": rtxt}]}
59
+ return message
60
+
61
+ if compat in ("legacy", "current"):
62
+ if reasoning_summary_text:
63
+ message["reasoning_summary"] = reasoning_summary_text
64
+ if reasoning_full_text:
65
+ message["reasoning"] = reasoning_full_text
66
+ return message
67
+
68
+ rtxt_parts: list[str] = []
69
+ if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
70
+ rtxt_parts.append(reasoning_summary_text)
71
+ if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
72
+ rtxt_parts.append(reasoning_full_text)
73
+ rtxt = "\n\n".join([p for p in rtxt_parts if p])
74
+ if rtxt:
75
+ think_block = f"<think>{rtxt}</think>"
76
+ content_text = message.get("content") or ""
77
+ if isinstance(content_text, str):
78
+ message["content"] = think_block + (content_text or "")
79
+ return message
build/lib/chatmock/responses_api.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Iterable, Iterator, List
6
+
7
+ from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
8
+ from .fast_mode import ServiceTierResolution, resolve_service_tier
9
+ from .model_registry import (
10
+ allowed_efforts_for_model,
11
+ extract_reasoning_from_model_name,
12
+ normalize_model_name,
13
+ uses_codex_instructions,
14
+ )
15
+ from .reasoning import build_reasoning_param
16
+ from .session import ensure_session_id
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ResponsesRequestError(Exception):
21
+ message: str
22
+ status_code: int = 400
23
+ code: str | None = None
24
+
25
+ def __str__(self) -> str:
26
+ return self.message
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class NormalizedResponsesRequest:
31
+ payload: Dict[str, Any]
32
+ requested_model: str | None
33
+ normalized_model: str
34
+ session_id: str
35
+ service_tier_resolution: ServiceTierResolution
36
+
37
+
38
+ def instructions_for_model(config: Dict[str, Any], model: str) -> str:
39
+ base = config.get("BASE_INSTRUCTIONS", BASE_INSTRUCTIONS)
40
+ if uses_codex_instructions(model):
41
+ codex = config.get("GPT5_CODEX_INSTRUCTIONS") or GPT5_CODEX_INSTRUCTIONS
42
+ if isinstance(codex, str) and codex.strip():
43
+ return codex
44
+ return base
45
+
46
+
47
+ def extract_client_session_id(headers: Any) -> str | None:
48
+ try:
49
+ return headers.get("X-Session-Id") or headers.get("session_id") or None
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _input_items_for_session(raw_input: Any) -> List[Dict[str, Any]]:
55
+ if isinstance(raw_input, list):
56
+ return [item for item in raw_input if isinstance(item, dict)]
57
+ if isinstance(raw_input, dict):
58
+ return [raw_input]
59
+ if isinstance(raw_input, str) and raw_input.strip():
60
+ return [
61
+ {
62
+ "type": "message",
63
+ "role": "user",
64
+ "content": [{"type": "input_text", "text": raw_input}],
65
+ }
66
+ ]
67
+ return []
68
+
69
+
70
+ def canonicalize_responses_input(raw_input: Any) -> Any:
71
+ if isinstance(raw_input, list):
72
+ return [item for item in raw_input if isinstance(item, dict)]
73
+ if isinstance(raw_input, dict):
74
+ return [raw_input]
75
+ if isinstance(raw_input, str):
76
+ return _input_items_for_session(raw_input)
77
+ return raw_input
78
+
79
+
80
+ def normalize_responses_payload(
81
+ payload: Dict[str, Any],
82
+ *,
83
+ config: Dict[str, Any],
84
+ client_session_id: str | None = None,
85
+ ) -> NormalizedResponsesRequest:
86
+ requested_model = payload.get("model") if isinstance(payload.get("model"), str) else None
87
+ normalized_model = normalize_model_name(requested_model, config.get("DEBUG_MODEL"))
88
+
89
+ normalized = dict(payload)
90
+ normalized["model"] = normalized_model
91
+ normalized.pop("max_output_tokens", None)
92
+
93
+ if "input" in normalized:
94
+ normalized["input"] = canonicalize_responses_input(normalized.get("input"))
95
+
96
+ if "store" not in normalized:
97
+ normalized["store"] = False
98
+
99
+ instructions = normalized.get("instructions")
100
+ if not isinstance(instructions, str) or not instructions.strip():
101
+ instructions = instructions_for_model(config, normalized_model)
102
+ normalized["instructions"] = instructions
103
+
104
+ reasoning_effort = config.get("REASONING_EFFORT", "medium")
105
+ reasoning_summary = config.get("REASONING_SUMMARY", "auto")
106
+ reasoning_overrides = (
107
+ normalized.get("reasoning")
108
+ if isinstance(normalized.get("reasoning"), dict)
109
+ else extract_reasoning_from_model_name(requested_model)
110
+ )
111
+ normalized["reasoning"] = build_reasoning_param(
112
+ reasoning_effort,
113
+ reasoning_summary,
114
+ reasoning_overrides,
115
+ allowed_efforts=allowed_efforts_for_model(normalized_model),
116
+ )
117
+
118
+ include = normalized.get("include")
119
+ include_list = [item for item in include if isinstance(item, str)] if isinstance(include, list) else []
120
+ if "reasoning.encrypted_content" not in include_list:
121
+ include_list.append("reasoning.encrypted_content")
122
+ normalized["include"] = include_list
123
+
124
+ tools = normalized.get("tools")
125
+ if (not isinstance(tools, list) or not tools) and bool(config.get("DEFAULT_WEB_SEARCH")):
126
+ tool_choice = normalized.get("tool_choice")
127
+ if not (isinstance(tool_choice, str) and tool_choice.strip().lower() == "none"):
128
+ normalized["tools"] = [{"type": "web_search"}]
129
+
130
+ service_tier_resolution = resolve_service_tier(
131
+ normalized_model,
132
+ request_fast_mode=normalized.get("fast_mode"),
133
+ request_service_tier=normalized.get("service_tier"),
134
+ server_fast_mode=bool(config.get("FAST_MODE")),
135
+ )
136
+ if service_tier_resolution.error_message:
137
+ raise ResponsesRequestError(service_tier_resolution.error_message)
138
+ if service_tier_resolution.service_tier is None:
139
+ normalized.pop("service_tier", None)
140
+ else:
141
+ normalized["service_tier"] = service_tier_resolution.service_tier
142
+ normalized.pop("fast_mode", None)
143
+
144
+ input_items = _input_items_for_session(normalized.get("input"))
145
+ session_id = ensure_session_id(instructions, input_items, client_session_id)
146
+ prompt_cache_key = normalized.get("prompt_cache_key")
147
+ if not isinstance(prompt_cache_key, str) or not prompt_cache_key.strip():
148
+ normalized["prompt_cache_key"] = session_id
149
+
150
+ return NormalizedResponsesRequest(
151
+ payload=normalized,
152
+ requested_model=requested_model,
153
+ normalized_model=normalized_model,
154
+ session_id=session_id,
155
+ service_tier_resolution=service_tier_resolution,
156
+ )
157
+
158
+
159
+ def iter_sse_event_payloads(upstream: Any) -> Iterator[Dict[str, Any]]:
160
+ for raw in upstream.iter_lines(decode_unicode=False):
161
+ if not raw:
162
+ continue
163
+ line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
164
+ if not line.startswith("data: "):
165
+ continue
166
+ data = line[len("data: ") :].strip()
167
+ if not data or data == "[DONE]":
168
+ if data == "[DONE]":
169
+ break
170
+ continue
171
+ try:
172
+ evt = json.loads(data)
173
+ except Exception:
174
+ continue
175
+ if isinstance(evt, dict):
176
+ yield evt
177
+
178
+
179
+ def aggregate_response_from_sse(
180
+ upstream: Any,
181
+ *,
182
+ on_event: Any | None = None,
183
+ ) -> tuple[Dict[str, Any] | None, Dict[str, Any] | None]:
184
+ response_obj: Dict[str, Any] | None = None
185
+ error_obj: Dict[str, Any] | None = None
186
+ try:
187
+ for evt in iter_sse_event_payloads(upstream):
188
+ if callable(on_event):
189
+ try:
190
+ on_event(evt)
191
+ except Exception:
192
+ pass
193
+ response = evt.get("response")
194
+ if isinstance(response, dict):
195
+ response_obj = response
196
+ kind = evt.get("type")
197
+ if kind == "response.failed":
198
+ if isinstance(response, dict) and isinstance(response.get("error"), dict):
199
+ error_obj = {"error": response.get("error")}
200
+ else:
201
+ error_obj = {"error": {"message": "response.failed"}}
202
+ break
203
+ if kind == "response.completed":
204
+ break
205
+ finally:
206
+ upstream.close()
207
+ return response_obj, error_obj
208
+
209
+
210
+ def stream_upstream_bytes(
211
+ upstream: Any,
212
+ *,
213
+ on_event: Any | None = None,
214
+ ) -> Iterable[bytes]:
215
+ buffer = b""
216
+ try:
217
+ for chunk in upstream.iter_content(chunk_size=None):
218
+ if chunk:
219
+ if callable(on_event):
220
+ if isinstance(chunk, bytes):
221
+ buffer += chunk
222
+ else:
223
+ buffer += str(chunk).encode("utf-8", errors="ignore")
224
+ while b"\n" in buffer:
225
+ line, buffer = buffer.split(b"\n", 1)
226
+ line = line.rstrip(b"\r")
227
+ if not line.startswith(b"data: "):
228
+ continue
229
+ data = line[len(b"data: ") :].strip()
230
+ if not data or data == b"[DONE]":
231
+ continue
232
+ try:
233
+ evt = json.loads(data.decode("utf-8", errors="ignore"))
234
+ except Exception:
235
+ evt = None
236
+ if isinstance(evt, dict):
237
+ try:
238
+ on_event(evt)
239
+ except Exception:
240
+ pass
241
+ yield chunk
242
+ finally:
243
+ upstream.close()
build/lib/chatmock/routes_ollama.py ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import datetime
5
+ import time
6
+ from typing import Any, Dict, List
7
+
8
+ from flask import Blueprint, Response, current_app, jsonify, make_response, request, stream_with_context
9
+
10
+ from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
11
+ from .fast_mode import resolve_service_tier
12
+ from .limits import record_rate_limits_from_response
13
+ from .http import build_cors_headers
14
+ from .model_registry import list_public_models, uses_codex_instructions
15
+ from .responses_api import instructions_for_model
16
+ from .reasoning import (
17
+ allowed_efforts_for_model,
18
+ build_reasoning_param,
19
+ extract_reasoning_from_model_name,
20
+ )
21
+ from .transform import convert_ollama_messages, normalize_ollama_tools
22
+ from .upstream import normalize_model_name, start_upstream_request
23
+ from .utils import convert_chat_messages_to_responses_input, convert_tools_chat_to_responses
24
+
25
+
26
+ ollama_bp = Blueprint("ollama", __name__)
27
+
28
+
29
+ def _log_json(prefix: str, payload: Any) -> None:
30
+ try:
31
+ print(f"{prefix}\n{json.dumps(payload, indent=2, ensure_ascii=False)}")
32
+ except Exception:
33
+ try:
34
+ print(f"{prefix}\n{payload}")
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ def _wrap_stream_logging(label: str, iterator, enabled: bool):
40
+ if not enabled:
41
+ return iterator
42
+
43
+ def _gen():
44
+ for chunk in iterator:
45
+ try:
46
+ text = (
47
+ chunk.decode("utf-8", errors="replace")
48
+ if isinstance(chunk, (bytes, bytearray))
49
+ else str(chunk)
50
+ )
51
+ print(f"{label}\n{text}")
52
+ except Exception:
53
+ pass
54
+ yield chunk
55
+
56
+ return _gen()
57
+
58
+
59
+ @ollama_bp.route("/api/version", methods=["GET"])
60
+ def ollama_version() -> Response:
61
+ if bool(current_app.config.get("VERBOSE")):
62
+ print("IN GET /api/version")
63
+ version = current_app.config.get("OLLAMA_VERSION", "0.12.10")
64
+ if not isinstance(version, str) or not version.strip():
65
+ version = "0.12.10"
66
+ payload = {"version": version}
67
+ resp = make_response(jsonify(payload), 200)
68
+ for k, v in build_cors_headers().items():
69
+ resp.headers.setdefault(k, v)
70
+ if bool(current_app.config.get("VERBOSE")):
71
+ _log_json("OUT GET /api/version", payload)
72
+ return resp
73
+
74
+
75
+ def _instructions_for_model(model: str) -> str:
76
+ return instructions_for_model(current_app.config, model)
77
+
78
+
79
+ _OLLAMA_FAKE_EVAL = {
80
+ "total_duration": 8497226791,
81
+ "load_duration": 1747193958,
82
+ "prompt_eval_count": 24,
83
+ "prompt_eval_duration": 269219750,
84
+ "eval_count": 247,
85
+ "eval_duration": 6413802458,
86
+ }
87
+
88
+
89
+ @ollama_bp.route("/api/tags", methods=["GET"])
90
+ def ollama_tags() -> Response:
91
+ if bool(current_app.config.get("VERBOSE")):
92
+ print("IN GET /api/tags")
93
+ expose_variants = bool(current_app.config.get("EXPOSE_REASONING_MODELS"))
94
+ model_ids = list_public_models(expose_reasoning_models=expose_variants)
95
+ models = []
96
+ for model_id in model_ids:
97
+ models.append(
98
+ {
99
+ "name": model_id,
100
+ "model": model_id,
101
+ "modified_at": "2023-10-01T00:00:00Z",
102
+ "size": 815319791,
103
+ "digest": "8648f39daa8fbf5b18c7b4e6a8fb4990c692751d49917417b8842ca5758e7ffc",
104
+ "details": {
105
+ "parent_model": "",
106
+ "format": "gguf",
107
+ "family": "llama",
108
+ "families": ["llama"],
109
+ "parameter_size": "8.0B",
110
+ "quantization_level": "Q4_0",
111
+ },
112
+ }
113
+ )
114
+ payload = {"models": models}
115
+ resp = make_response(jsonify(payload), 200)
116
+ for k, v in build_cors_headers().items():
117
+ resp.headers.setdefault(k, v)
118
+ if bool(current_app.config.get("VERBOSE")):
119
+ _log_json("OUT GET /api/tags", payload)
120
+ return resp
121
+
122
+
123
+ @ollama_bp.route("/api/show", methods=["POST"])
124
+ def ollama_show() -> Response:
125
+ verbose = bool(current_app.config.get("VERBOSE"))
126
+ raw_body = request.get_data(cache=True, as_text=True) or ""
127
+ if verbose:
128
+ try:
129
+ print("IN POST /api/show\n" + raw_body)
130
+ except Exception:
131
+ pass
132
+ try:
133
+ payload = json.loads(raw_body) if raw_body else (request.get_json(silent=True) or {})
134
+ except Exception:
135
+ payload = request.get_json(silent=True) or {}
136
+ model = payload.get("model")
137
+ if not isinstance(model, str) or not model.strip():
138
+ err = {"error": "Model not found"}
139
+ if verbose:
140
+ _log_json("OUT POST /api/show", err)
141
+ return jsonify(err), 400
142
+ v1_show_response = {
143
+ "modelfile": "# Modelfile generated by \"ollama show\"\n# To build a new Modelfile based on this one, replace the FROM line with:\n# FROM llava:latest\n\nFROM /models/blobs/sha256:placeholder\nTEMPLATE \"\"\"{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: \"\"\"\nPARAMETER num_ctx 100000\nPARAMETER stop \"</s>\"\nPARAMETER stop \"USER:\"\nPARAMETER stop \"ASSISTANT:\"",
144
+ "parameters": "num_keep 24\nstop \"<|start_header_id|>\"\nstop \"<|end_header_id|>\"\nstop \"<|eot_id|>\"",
145
+ "template": "{{ if .System }}<|start_header_id|>system<|end_header_id|>\n\n{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>\n\n{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>\n\n{{ .Response }}<|eot_id|>",
146
+ "details": {
147
+ "parent_model": "",
148
+ "format": "gguf",
149
+ "family": "llama",
150
+ "families": ["llama"],
151
+ "parameter_size": "8.0B",
152
+ "quantization_level": "Q4_0",
153
+ },
154
+ "model_info": {
155
+ "general.architecture": "llama",
156
+ "general.file_type": 2,
157
+ "llama.context_length": 2000000,
158
+ },
159
+ "capabilities": ["completion", "vision", "tools", "thinking"],
160
+ }
161
+ if verbose:
162
+ _log_json("OUT POST /api/show", v1_show_response)
163
+ resp = make_response(jsonify(v1_show_response), 200)
164
+ for k, v in build_cors_headers().items():
165
+ resp.headers.setdefault(k, v)
166
+ return resp
167
+
168
+
169
+ @ollama_bp.route("/api/chat", methods=["POST"])
170
+ def ollama_chat() -> Response:
171
+ verbose = bool(current_app.config.get("VERBOSE"))
172
+ reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
173
+ reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
174
+ reasoning_compat = current_app.config.get("REASONING_COMPAT", "think-tags")
175
+
176
+ try:
177
+ raw = request.get_data(cache=True, as_text=True) or ""
178
+ if verbose:
179
+ print("IN POST /api/chat\n" + (raw if isinstance(raw, str) else ""))
180
+ payload = json.loads(raw) if raw else {}
181
+ except Exception:
182
+ err = {"error": "Invalid JSON body"}
183
+ if verbose:
184
+ _log_json("OUT POST /api/chat", err)
185
+ return jsonify(err), 400
186
+
187
+ model = payload.get("model")
188
+ raw_messages = payload.get("messages")
189
+ messages = convert_ollama_messages(
190
+ raw_messages, payload.get("images") if isinstance(payload.get("images"), list) else None
191
+ )
192
+ if isinstance(messages, list):
193
+ sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
194
+ if isinstance(sys_idx, int):
195
+ sys_msg = messages.pop(sys_idx)
196
+ content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
197
+ messages.insert(0, {"role": "user", "content": content})
198
+ stream_req = payload.get("stream")
199
+ if stream_req is None:
200
+ stream_req = True
201
+ stream_req = bool(stream_req)
202
+ tools_req = payload.get("tools") if isinstance(payload.get("tools"), list) else []
203
+ tools_responses = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
204
+ tool_choice = payload.get("tool_choice", "auto")
205
+ parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
206
+
207
+ # Passthrough Responses API tools (web_search) via ChatMock extension fields
208
+ extra_tools: List[Dict[str, Any]] = []
209
+ had_responses_tools = False
210
+ rt_payload = payload.get("responses_tools") if isinstance(payload.get("responses_tools"), list) else []
211
+ if isinstance(rt_payload, list):
212
+ for _t in rt_payload:
213
+ if not (isinstance(_t, dict) and isinstance(_t.get("type"), str)):
214
+ continue
215
+ if _t.get("type") not in ("web_search", "web_search_preview"):
216
+ err = {"error": "Only web_search/web_search_preview are supported in responses_tools"}
217
+ if verbose:
218
+ _log_json("OUT POST /api/chat", err)
219
+ return jsonify(err), 400
220
+ extra_tools.append(_t)
221
+ if not extra_tools and bool(current_app.config.get("DEFAULT_WEB_SEARCH")):
222
+ rtc = payload.get("responses_tool_choice")
223
+ if not (isinstance(rtc, str) and rtc == "none"):
224
+ extra_tools = [{"type": "web_search"}]
225
+ if extra_tools:
226
+ import json as _json
227
+ MAX_TOOLS_BYTES = 32768
228
+ try:
229
+ size = len(_json.dumps(extra_tools))
230
+ except Exception:
231
+ size = 0
232
+ if size > MAX_TOOLS_BYTES:
233
+ err = {"error": "responses_tools too large"}
234
+ if verbose:
235
+ _log_json("OUT POST /api/chat", err)
236
+ return jsonify(err), 400
237
+ had_responses_tools = True
238
+ tools_responses = (tools_responses or []) + extra_tools
239
+
240
+ rtc = payload.get("responses_tool_choice")
241
+ if isinstance(rtc, str) and rtc in ("auto", "none"):
242
+ tool_choice = rtc
243
+
244
+ if not isinstance(model, str) or not isinstance(messages, list) or not messages:
245
+ err = {"error": "Invalid request format"}
246
+ if verbose:
247
+ _log_json("OUT POST /api/chat", err)
248
+ return jsonify(err), 400
249
+
250
+ input_items = convert_chat_messages_to_responses_input(messages)
251
+
252
+ model_reasoning = extract_reasoning_from_model_name(model)
253
+ normalized_model = normalize_model_name(model, current_app.config.get("DEBUG_MODEL"))
254
+ service_tier_resolution = resolve_service_tier(
255
+ normalized_model,
256
+ request_fast_mode=payload.get("fast_mode"),
257
+ request_service_tier=payload.get("service_tier"),
258
+ server_fast_mode=bool(current_app.config.get("FAST_MODE")),
259
+ )
260
+ if service_tier_resolution.warning_message and verbose:
261
+ print(f"[FastMode] {service_tier_resolution.warning_message}")
262
+ if service_tier_resolution.error_message:
263
+ err = {"error": service_tier_resolution.error_message}
264
+ if verbose:
265
+ _log_json("OUT POST /api/chat", err)
266
+ return jsonify(err), 400
267
+ upstream, error_resp = start_upstream_request(
268
+ normalized_model,
269
+ input_items,
270
+ instructions=_instructions_for_model(normalized_model),
271
+ tools=tools_responses,
272
+ tool_choice=tool_choice,
273
+ parallel_tool_calls=parallel_tool_calls,
274
+ reasoning_param=build_reasoning_param(
275
+ reasoning_effort,
276
+ reasoning_summary,
277
+ model_reasoning,
278
+ allowed_efforts=allowed_efforts_for_model(model),
279
+ ),
280
+ service_tier=service_tier_resolution.service_tier,
281
+ )
282
+ if error_resp is not None:
283
+ if verbose:
284
+ try:
285
+ body = error_resp.get_data(as_text=True)
286
+ if body:
287
+ try:
288
+ parsed = json.loads(body)
289
+ except Exception:
290
+ parsed = body
291
+ _log_json("OUT POST /api/chat", parsed)
292
+ except Exception:
293
+ pass
294
+ return error_resp
295
+
296
+ record_rate_limits_from_response(upstream)
297
+
298
+ if upstream.status_code >= 400:
299
+ try:
300
+ err_body = json.loads(upstream.content.decode("utf-8", errors="ignore")) if upstream.content else {"raw": upstream.text}
301
+ except Exception:
302
+ err_body = {"raw": upstream.text}
303
+ if had_responses_tools:
304
+ if verbose:
305
+ print("[Passthrough] Upstream rejected tools; retrying without extras (args redacted)")
306
+ base_tools_only = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
307
+ safe_choice = payload.get("tool_choice", "auto")
308
+ upstream2, err2 = start_upstream_request(
309
+ normalize_model_name(model, current_app.config.get("DEBUG_MODEL")),
310
+ input_items,
311
+ instructions=BASE_INSTRUCTIONS,
312
+ tools=base_tools_only,
313
+ tool_choice=safe_choice,
314
+ parallel_tool_calls=parallel_tool_calls,
315
+ reasoning_param=build_reasoning_param(
316
+ reasoning_effort,
317
+ reasoning_summary,
318
+ model_reasoning,
319
+ allowed_efforts=allowed_efforts_for_model(model),
320
+ ),
321
+ service_tier=service_tier_resolution.service_tier,
322
+ )
323
+ record_rate_limits_from_response(upstream2)
324
+ if err2 is None and upstream2 is not None and upstream2.status_code < 400:
325
+ upstream = upstream2
326
+ else:
327
+ err = {"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error"), "code": "RESPONSES_TOOLS_REJECTED"}}
328
+ if verbose:
329
+ _log_json("OUT POST /api/chat", err)
330
+ return jsonify(err), (upstream2.status_code if upstream2 is not None else upstream.status_code)
331
+ else:
332
+ if verbose:
333
+ print("/api/chat upstream error status=", upstream.status_code, " body:", json.dumps(err_body)[:2000])
334
+ err = {"error": (err_body.get("error", {}) or {}).get("message", "Upstream error")}
335
+ if verbose:
336
+ _log_json("OUT POST /api/chat", err)
337
+ return jsonify(err), upstream.status_code
338
+
339
+ created_at = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
340
+ model_out = model if isinstance(model, str) and model.strip() else normalized_model
341
+
342
+ if stream_req:
343
+ def _gen():
344
+ compat = (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower()
345
+ think_open = False
346
+ think_closed = False
347
+ saw_any_summary = False
348
+ pending_summary_paragraph = False
349
+ full_parts: List[str] = []
350
+ try:
351
+ for raw_line in upstream.iter_lines(decode_unicode=False):
352
+ if not raw_line:
353
+ continue
354
+ line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
355
+ if not line.startswith("data: "):
356
+ continue
357
+ data = line[len("data: "):].strip()
358
+ if not data:
359
+ continue
360
+ if data == "[DONE]":
361
+ break
362
+ try:
363
+ evt = json.loads(data)
364
+ except Exception:
365
+ continue
366
+ kind = evt.get("type")
367
+ if kind == "response.reasoning_summary_part.added":
368
+ if compat in ("think-tags", "o3"):
369
+ if saw_any_summary:
370
+ pending_summary_paragraph = True
371
+ else:
372
+ saw_any_summary = True
373
+ elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
374
+ delta_txt = evt.get("delta") or ""
375
+ if compat == "o3":
376
+ if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
377
+ yield (
378
+ json.dumps(
379
+ {
380
+ "model": model_out,
381
+ "created_at": created_at,
382
+ "message": {"role": "assistant", "content": "\n"},
383
+ "done": False,
384
+ }
385
+ )
386
+ + "\n"
387
+ )
388
+ full_parts.append("\n")
389
+ pending_summary_paragraph = False
390
+ if delta_txt:
391
+ yield (
392
+ json.dumps(
393
+ {
394
+ "model": model_out,
395
+ "created_at": created_at,
396
+ "message": {"role": "assistant", "content": delta_txt},
397
+ "done": False,
398
+ }
399
+ )
400
+ + "\n"
401
+ )
402
+ full_parts.append(delta_txt)
403
+ elif compat == "think-tags":
404
+ if not think_open and not think_closed:
405
+ yield (
406
+ json.dumps(
407
+ {
408
+ "model": model_out,
409
+ "created_at": created_at,
410
+ "message": {"role": "assistant", "content": "<think>"},
411
+ "done": False,
412
+ }
413
+ )
414
+ + "\n"
415
+ )
416
+ full_parts.append("<think>")
417
+ think_open = True
418
+ if think_open and not think_closed:
419
+ if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
420
+ yield (
421
+ json.dumps(
422
+ {
423
+ "model": model_out,
424
+ "created_at": created_at,
425
+ "message": {"role": "assistant", "content": "\n"},
426
+ "done": False,
427
+ }
428
+ )
429
+ + "\n"
430
+ )
431
+ full_parts.append("\n")
432
+ pending_summary_paragraph = False
433
+ if delta_txt:
434
+ yield (
435
+ json.dumps(
436
+ {
437
+ "model": model_out,
438
+ "created_at": created_at,
439
+ "message": {"role": "assistant", "content": delta_txt},
440
+ "done": False,
441
+ }
442
+ )
443
+ + "\n"
444
+ )
445
+ full_parts.append(delta_txt)
446
+ else:
447
+ pass
448
+ elif kind == "response.output_text.delta":
449
+ delta = evt.get("delta") or ""
450
+ if compat == "think-tags" and think_open and not think_closed:
451
+ yield (
452
+ json.dumps(
453
+ {
454
+ "model": model_out,
455
+ "created_at": created_at,
456
+ "message": {"role": "assistant", "content": "</think>"},
457
+ "done": False,
458
+ }
459
+ )
460
+ + "\n"
461
+ )
462
+ full_parts.append("</think>")
463
+ think_open = False
464
+ think_closed = True
465
+ if delta:
466
+ yield (
467
+ json.dumps(
468
+ {
469
+ "model": model_out,
470
+ "created_at": created_at,
471
+ "message": {"role": "assistant", "content": delta},
472
+ "done": False,
473
+ }
474
+ )
475
+ + "\n"
476
+ )
477
+ full_parts.append(delta)
478
+ elif kind == "response.completed":
479
+ break
480
+ finally:
481
+ upstream.close()
482
+ if compat == "think-tags" and think_open and not think_closed:
483
+ yield (
484
+ json.dumps(
485
+ {
486
+ "model": model_out,
487
+ "created_at": created_at,
488
+ "message": {"role": "assistant", "content": "</think>"},
489
+ "done": False,
490
+ }
491
+ )
492
+ + "\n"
493
+ )
494
+ full_parts.append("</think>")
495
+ done_obj = {
496
+ "model": model_out,
497
+ "created_at": created_at,
498
+ "message": {"role": "assistant", "content": ""},
499
+ "done": True,
500
+ }
501
+ done_obj.update(_OLLAMA_FAKE_EVAL)
502
+ yield json.dumps(done_obj) + "\n"
503
+ if verbose:
504
+ print("OUT POST /api/chat (streaming response)")
505
+ stream_iter = stream_with_context(_gen())
506
+ stream_iter = _wrap_stream_logging("STREAM OUT /api/chat", stream_iter, verbose)
507
+ resp = current_app.response_class(
508
+ stream_iter,
509
+ status=200,
510
+ mimetype="application/x-ndjson",
511
+ )
512
+ for k, v in build_cors_headers().items():
513
+ resp.headers.setdefault(k, v)
514
+ return resp
515
+
516
+ full_text = ""
517
+ reasoning_summary_text = ""
518
+ reasoning_full_text = ""
519
+ tool_calls: List[Dict[str, Any]] = []
520
+ try:
521
+ for raw in upstream.iter_lines(decode_unicode=False):
522
+ if not raw:
523
+ continue
524
+ line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
525
+ if not line.startswith("data: "):
526
+ continue
527
+ data = line[len("data: "):].strip()
528
+ if not data:
529
+ continue
530
+ if data == "[DONE]":
531
+ break
532
+ try:
533
+ evt = json.loads(data)
534
+ except Exception:
535
+ continue
536
+ kind = evt.get("type")
537
+ if kind == "response.output_text.delta":
538
+ full_text += evt.get("delta") or ""
539
+ elif kind == "response.reasoning_summary_text.delta":
540
+ reasoning_summary_text += evt.get("delta") or ""
541
+ elif kind == "response.reasoning_text.delta":
542
+ reasoning_full_text += evt.get("delta") or ""
543
+ elif kind == "response.output_item.done":
544
+ item = evt.get("item") or {}
545
+ if isinstance(item, dict) and item.get("type") == "function_call":
546
+ call_id = item.get("call_id") or item.get("id") or ""
547
+ name = item.get("name") or ""
548
+ args = item.get("arguments") or ""
549
+ if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
550
+ tool_calls.append(
551
+ {
552
+ "id": call_id,
553
+ "type": "function",
554
+ "function": {"name": name, "arguments": args},
555
+ }
556
+ )
557
+ elif kind == "response.completed":
558
+ break
559
+ finally:
560
+ upstream.close()
561
+
562
+ if (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower() == "think-tags":
563
+ rtxt_parts = []
564
+ if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
565
+ rtxt_parts.append(reasoning_summary_text)
566
+ if isinstance(reasoning_full_text, str) and reasoning_full_text.strip():
567
+ rtxt_parts.append(reasoning_full_text)
568
+ rtxt = "\n\n".join([p for p in rtxt_parts if p])
569
+ if rtxt:
570
+ full_text = f"<think>{rtxt}</think>" + (full_text or "")
571
+
572
+ out_json = {
573
+ "model": normalize_model_name(model, current_app.config.get("DEBUG_MODEL")),
574
+ "created_at": created_at,
575
+ "message": {"role": "assistant", "content": full_text, **({"tool_calls": tool_calls} if tool_calls else {})},
576
+ "done": True,
577
+ "done_reason": "stop",
578
+ }
579
+ out_json.update(_OLLAMA_FAKE_EVAL)
580
+ if verbose:
581
+ _log_json("OUT POST /api/chat", out_json)
582
+ resp = make_response(jsonify(out_json), 200)
583
+ for k, v in build_cors_headers().items():
584
+ resp.headers.setdefault(k, v)
585
+ return resp
build/lib/chatmock/routes_openai.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from typing import Any, Dict, List
6
+
7
+ from flask import Blueprint, Response, current_app, jsonify, make_response, request
8
+
9
+ from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
10
+ from .fast_mode import resolve_service_tier
11
+ from .limits import record_rate_limits_from_response
12
+ from .http import build_cors_headers
13
+ from .model_registry import list_public_models, uses_codex_instructions
14
+ from .responses_api import (
15
+ ResponsesRequestError,
16
+ aggregate_response_from_sse,
17
+ extract_client_session_id,
18
+ instructions_for_model,
19
+ normalize_responses_payload,
20
+ stream_upstream_bytes,
21
+ )
22
+ from .reasoning import (
23
+ allowed_efforts_for_model,
24
+ apply_reasoning_to_message,
25
+ build_reasoning_param,
26
+ extract_reasoning_from_model_name,
27
+ )
28
+ from .session import (
29
+ clear_responses_reuse_state,
30
+ note_responses_final_response,
31
+ note_responses_stream_event,
32
+ prepare_responses_request_for_session,
33
+ )
34
+ from .upstream import normalize_model_name, start_upstream_raw_request, start_upstream_request
35
+ from .utils import (
36
+ convert_chat_messages_to_responses_input,
37
+ convert_tools_chat_to_responses,
38
+ sse_translate_chat,
39
+ sse_translate_text,
40
+ )
41
+
42
+
43
+ openai_bp = Blueprint("openai", __name__)
44
+
45
+
46
+ def _log_json(prefix: str, payload: Any) -> None:
47
+ try:
48
+ print(f"{prefix}\n{json.dumps(payload, indent=2, ensure_ascii=False)}")
49
+ except Exception:
50
+ try:
51
+ print(f"{prefix}\n{payload}")
52
+ except Exception:
53
+ pass
54
+
55
+
56
+ def _wrap_stream_logging(label: str, iterator, enabled: bool):
57
+ if not enabled:
58
+ return iterator
59
+
60
+ def _gen():
61
+ for chunk in iterator:
62
+ try:
63
+ text = (
64
+ chunk.decode("utf-8", errors="replace")
65
+ if isinstance(chunk, (bytes, bytearray))
66
+ else str(chunk)
67
+ )
68
+ print(f"{label}\n{text}")
69
+ except Exception:
70
+ pass
71
+ yield chunk
72
+
73
+ return _gen()
74
+
75
+
76
+ def _instructions_for_model(model: str) -> str:
77
+ return instructions_for_model(current_app.config, model)
78
+
79
+
80
+ def _service_tier_from_payload(
81
+ model: str,
82
+ payload: Dict[str, Any],
83
+ *,
84
+ verbose: bool = False,
85
+ ) -> tuple[str | None, Response | None]:
86
+ resolution = resolve_service_tier(
87
+ model,
88
+ request_fast_mode=payload.get("fast_mode"),
89
+ request_service_tier=payload.get("service_tier"),
90
+ server_fast_mode=bool(current_app.config.get("FAST_MODE")),
91
+ )
92
+ if resolution.warning_message and verbose:
93
+ print(f"[FastMode] {resolution.warning_message}")
94
+ if resolution.error_message:
95
+ err = {"error": {"message": resolution.error_message}}
96
+ if verbose:
97
+ _log_json("OUT POST service_tier resolution", err)
98
+ resp = make_response(jsonify(err), 400)
99
+ for k, v in build_cors_headers().items():
100
+ resp.headers.setdefault(k, v)
101
+ return None, resp
102
+ return resolution.service_tier, None
103
+
104
+
105
+ @openai_bp.route("/v1/chat/completions", methods=["POST"])
106
+ def chat_completions() -> Response:
107
+ verbose = bool(current_app.config.get("VERBOSE"))
108
+ verbose_obfuscation = bool(current_app.config.get("VERBOSE_OBFUSCATION"))
109
+ reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
110
+ reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
111
+ reasoning_compat = current_app.config.get("REASONING_COMPAT", "think-tags")
112
+
113
+ raw = request.get_data(cache=True, as_text=True) or ""
114
+ if verbose:
115
+ try:
116
+ print("IN POST /v1/chat/completions\n" + raw)
117
+ except Exception:
118
+ pass
119
+ try:
120
+ payload = json.loads(raw) if raw else {}
121
+ except Exception:
122
+ try:
123
+ payload = json.loads(raw.replace("\r", "").replace("\n", ""))
124
+ except Exception:
125
+ err = {"error": {"message": "Invalid JSON body"}}
126
+ if verbose:
127
+ _log_json("OUT POST /v1/chat/completions", err)
128
+ return jsonify(err), 400
129
+
130
+ requested_model = payload.get("model")
131
+ model = normalize_model_name(requested_model, current_app.config.get("DEBUG_MODEL"))
132
+ messages = payload.get("messages")
133
+ if messages is None and isinstance(payload.get("prompt"), str):
134
+ messages = [{"role": "user", "content": payload.get("prompt") or ""}]
135
+ if messages is None and isinstance(payload.get("input"), str):
136
+ messages = [{"role": "user", "content": payload.get("input") or ""}]
137
+ if messages is None:
138
+ messages = []
139
+ if not isinstance(messages, list):
140
+ err = {"error": {"message": "Request must include messages: []"}}
141
+ if verbose:
142
+ _log_json("OUT POST /v1/chat/completions", err)
143
+ return jsonify(err), 400
144
+
145
+ if isinstance(messages, list):
146
+ sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
147
+ if isinstance(sys_idx, int):
148
+ sys_msg = messages.pop(sys_idx)
149
+ content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
150
+ messages.insert(0, {"role": "user", "content": content})
151
+ is_stream = bool(payload.get("stream"))
152
+ stream_options = payload.get("stream_options") if isinstance(payload.get("stream_options"), dict) else {}
153
+ include_usage = bool(stream_options.get("include_usage", False))
154
+
155
+ tools_responses = convert_tools_chat_to_responses(payload.get("tools"))
156
+ tool_choice = payload.get("tool_choice", "auto")
157
+ parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
158
+ responses_tools_payload = payload.get("responses_tools") if isinstance(payload.get("responses_tools"), list) else []
159
+ extra_tools: List[Dict[str, Any]] = []
160
+ had_responses_tools = False
161
+ if isinstance(responses_tools_payload, list):
162
+ for _t in responses_tools_payload:
163
+ if not (isinstance(_t, dict) and isinstance(_t.get("type"), str)):
164
+ continue
165
+ if _t.get("type") not in ("web_search", "web_search_preview"):
166
+ err = {
167
+ "error": {
168
+ "message": "Only web_search/web_search_preview are supported in responses_tools",
169
+ "code": "RESPONSES_TOOL_UNSUPPORTED",
170
+ }
171
+ }
172
+ if verbose:
173
+ _log_json("OUT POST /v1/chat/completions", err)
174
+ return jsonify(err), 400
175
+ extra_tools.append(_t)
176
+
177
+ if not extra_tools and bool(current_app.config.get("DEFAULT_WEB_SEARCH")):
178
+ responses_tool_choice = payload.get("responses_tool_choice")
179
+ if not (isinstance(responses_tool_choice, str) and responses_tool_choice == "none"):
180
+ extra_tools = [{"type": "web_search"}]
181
+
182
+ if extra_tools:
183
+ import json as _json
184
+ MAX_TOOLS_BYTES = 32768
185
+ try:
186
+ size = len(_json.dumps(extra_tools))
187
+ except Exception:
188
+ size = 0
189
+ if size > MAX_TOOLS_BYTES:
190
+ err = {"error": {"message": "responses_tools too large", "code": "RESPONSES_TOOLS_TOO_LARGE"}}
191
+ if verbose:
192
+ _log_json("OUT POST /v1/chat/completions", err)
193
+ return jsonify(err), 400
194
+ had_responses_tools = True
195
+ tools_responses = (tools_responses or []) + extra_tools
196
+
197
+ responses_tool_choice = payload.get("responses_tool_choice")
198
+ if isinstance(responses_tool_choice, str) and responses_tool_choice in ("auto", "none"):
199
+ tool_choice = responses_tool_choice
200
+
201
+ input_items = convert_chat_messages_to_responses_input(messages)
202
+ if not input_items and isinstance(payload.get("prompt"), str) and payload.get("prompt").strip():
203
+ input_items = [
204
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": payload.get("prompt")}]}
205
+ ]
206
+
207
+ model_reasoning = extract_reasoning_from_model_name(requested_model)
208
+ reasoning_overrides = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else model_reasoning
209
+ reasoning_param = build_reasoning_param(
210
+ reasoning_effort,
211
+ reasoning_summary,
212
+ reasoning_overrides,
213
+ allowed_efforts=allowed_efforts_for_model(model),
214
+ )
215
+ service_tier, tier_error = _service_tier_from_payload(model, payload, verbose=verbose)
216
+ if tier_error is not None:
217
+ return tier_error
218
+
219
+ upstream, error_resp = start_upstream_request(
220
+ model,
221
+ input_items,
222
+ instructions=_instructions_for_model(model),
223
+ tools=tools_responses,
224
+ tool_choice=tool_choice,
225
+ parallel_tool_calls=parallel_tool_calls,
226
+ reasoning_param=reasoning_param,
227
+ service_tier=service_tier,
228
+ )
229
+ if error_resp is not None:
230
+ if verbose:
231
+ try:
232
+ body = error_resp.get_data(as_text=True)
233
+ if body:
234
+ try:
235
+ parsed = json.loads(body)
236
+ except Exception:
237
+ parsed = body
238
+ _log_json("OUT POST /v1/chat/completions", parsed)
239
+ except Exception:
240
+ pass
241
+ return error_resp
242
+
243
+ record_rate_limits_from_response(upstream)
244
+
245
+ created = int(time.time())
246
+ if upstream.status_code >= 400:
247
+ try:
248
+ raw = upstream.content
249
+ err_body = json.loads(raw.decode("utf-8", errors="ignore")) if raw else {"raw": upstream.text}
250
+ except Exception:
251
+ err_body = {"raw": upstream.text}
252
+ if had_responses_tools:
253
+ if verbose:
254
+ print("[Passthrough] Upstream rejected tools; retrying without extra tools (args redacted)")
255
+ base_tools_only = convert_tools_chat_to_responses(payload.get("tools"))
256
+ safe_choice = payload.get("tool_choice", "auto")
257
+ upstream2, err2 = start_upstream_request(
258
+ model,
259
+ input_items,
260
+ instructions=BASE_INSTRUCTIONS,
261
+ tools=base_tools_only,
262
+ tool_choice=safe_choice,
263
+ parallel_tool_calls=parallel_tool_calls,
264
+ reasoning_param=reasoning_param,
265
+ service_tier=service_tier,
266
+ )
267
+ record_rate_limits_from_response(upstream2)
268
+ if err2 is None and upstream2 is not None and upstream2.status_code < 400:
269
+ upstream = upstream2
270
+ else:
271
+ err = {
272
+ "error": {
273
+ "message": (err_body.get("error", {}) or {}).get("message", "Upstream error"),
274
+ "code": "RESPONSES_TOOLS_REJECTED",
275
+ }
276
+ }
277
+ if verbose:
278
+ _log_json("OUT POST /v1/chat/completions", err)
279
+ return jsonify(err), (upstream2.status_code if upstream2 is not None else upstream.status_code)
280
+ else:
281
+ if verbose:
282
+ print("Upstream error status=", upstream.status_code)
283
+ err = {"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error")}}
284
+ if verbose:
285
+ _log_json("OUT POST /v1/chat/completions", err)
286
+ return jsonify(err), upstream.status_code
287
+
288
+ if is_stream:
289
+ if verbose:
290
+ print("OUT POST /v1/chat/completions (streaming response)")
291
+ stream_iter = sse_translate_chat(
292
+ upstream,
293
+ requested_model or model,
294
+ created,
295
+ verbose=verbose_obfuscation,
296
+ vlog=print if verbose_obfuscation else None,
297
+ reasoning_compat=reasoning_compat,
298
+ include_usage=include_usage,
299
+ )
300
+ stream_iter = _wrap_stream_logging("STREAM OUT /v1/chat/completions", stream_iter, verbose)
301
+ resp = Response(
302
+ stream_iter,
303
+ status=upstream.status_code,
304
+ mimetype="text/event-stream",
305
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
306
+ )
307
+ for k, v in build_cors_headers().items():
308
+ resp.headers.setdefault(k, v)
309
+ return resp
310
+
311
+ full_text = ""
312
+ reasoning_summary_text = ""
313
+ reasoning_full_text = ""
314
+ response_id = "chatcmpl"
315
+ tool_calls: List[Dict[str, Any]] = []
316
+ error_message: str | None = None
317
+ usage_obj: Dict[str, int] | None = None
318
+
319
+ def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
320
+ try:
321
+ usage = (evt.get("response") or {}).get("usage")
322
+ if not isinstance(usage, dict):
323
+ return None
324
+ pt = int(usage.get("input_tokens") or 0)
325
+ ct = int(usage.get("output_tokens") or 0)
326
+ tt = int(usage.get("total_tokens") or (pt + ct))
327
+ return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
328
+ except Exception:
329
+ return None
330
+ try:
331
+ for raw in upstream.iter_lines(decode_unicode=False):
332
+ if not raw:
333
+ continue
334
+ line = raw.decode("utf-8", errors="ignore") if isinstance(raw, (bytes, bytearray)) else raw
335
+ if not line.startswith("data: "):
336
+ continue
337
+ data = line[len("data: "):].strip()
338
+ if not data:
339
+ continue
340
+ if data == "[DONE]":
341
+ break
342
+ try:
343
+ evt = json.loads(data)
344
+ except Exception:
345
+ continue
346
+ kind = evt.get("type")
347
+ mu = _extract_usage(evt)
348
+ if mu:
349
+ usage_obj = mu
350
+ if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
351
+ response_id = evt["response"].get("id") or response_id
352
+ if kind == "response.output_text.delta":
353
+ full_text += evt.get("delta") or ""
354
+ elif kind == "response.reasoning_summary_text.delta":
355
+ reasoning_summary_text += evt.get("delta") or ""
356
+ elif kind == "response.reasoning_text.delta":
357
+ reasoning_full_text += evt.get("delta") or ""
358
+ elif kind == "response.output_item.done":
359
+ item = evt.get("item") or {}
360
+ if isinstance(item, dict) and item.get("type") == "function_call":
361
+ call_id = item.get("call_id") or item.get("id") or ""
362
+ name = item.get("name") or ""
363
+ args = item.get("arguments") or ""
364
+ if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
365
+ tool_calls.append(
366
+ {
367
+ "id": call_id,
368
+ "type": "function",
369
+ "function": {"name": name, "arguments": args},
370
+ }
371
+ )
372
+ elif kind == "response.failed":
373
+ error_message = evt.get("response", {}).get("error", {}).get("message", "response.failed")
374
+ elif kind == "response.completed":
375
+ break
376
+ finally:
377
+ upstream.close()
378
+
379
+ if error_message:
380
+ resp = make_response(jsonify({"error": {"message": error_message}}), 502)
381
+ for k, v in build_cors_headers().items():
382
+ resp.headers.setdefault(k, v)
383
+ return resp
384
+
385
+ message: Dict[str, Any] = {"role": "assistant", "content": full_text if full_text else None}
386
+ if tool_calls:
387
+ message["tool_calls"] = tool_calls
388
+ message = apply_reasoning_to_message(message, reasoning_summary_text, reasoning_full_text, reasoning_compat)
389
+ completion = {
390
+ "id": response_id or "chatcmpl",
391
+ "object": "chat.completion",
392
+ "created": created,
393
+ "model": requested_model or model,
394
+ "choices": [
395
+ {
396
+ "index": 0,
397
+ "message": message,
398
+ "finish_reason": "stop",
399
+ }
400
+ ],
401
+ **({"usage": usage_obj} if usage_obj else {}),
402
+ }
403
+ if verbose:
404
+ _log_json("OUT POST /v1/chat/completions", completion)
405
+ resp = make_response(jsonify(completion), upstream.status_code)
406
+ for k, v in build_cors_headers().items():
407
+ resp.headers.setdefault(k, v)
408
+ return resp
409
+
410
+
411
+ @openai_bp.route("/v1/completions", methods=["POST"])
412
+ def completions() -> Response:
413
+ verbose = bool(current_app.config.get("VERBOSE"))
414
+ verbose_obfuscation = bool(current_app.config.get("VERBOSE_OBFUSCATION"))
415
+ reasoning_effort = current_app.config.get("REASONING_EFFORT", "medium")
416
+ reasoning_summary = current_app.config.get("REASONING_SUMMARY", "auto")
417
+
418
+ raw = request.get_data(cache=True, as_text=True) or ""
419
+ if verbose:
420
+ try:
421
+ print("IN POST /v1/completions\n" + raw)
422
+ except Exception:
423
+ pass
424
+ try:
425
+ payload = json.loads(raw) if raw else {}
426
+ except Exception:
427
+ err = {"error": {"message": "Invalid JSON body"}}
428
+ if verbose:
429
+ _log_json("OUT POST /v1/completions", err)
430
+ return jsonify(err), 400
431
+
432
+ requested_model = payload.get("model")
433
+ model = normalize_model_name(requested_model, current_app.config.get("DEBUG_MODEL"))
434
+ prompt = payload.get("prompt")
435
+ if isinstance(prompt, list):
436
+ prompt = "".join([p if isinstance(p, str) else "" for p in prompt])
437
+ if not isinstance(prompt, str):
438
+ prompt = payload.get("suffix") or ""
439
+ stream_req = bool(payload.get("stream", False))
440
+ stream_options = payload.get("stream_options") if isinstance(payload.get("stream_options"), dict) else {}
441
+ include_usage = bool(stream_options.get("include_usage", False))
442
+
443
+ messages = [{"role": "user", "content": prompt or ""}]
444
+ input_items = convert_chat_messages_to_responses_input(messages)
445
+
446
+ model_reasoning = extract_reasoning_from_model_name(requested_model)
447
+ reasoning_overrides = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else model_reasoning
448
+ reasoning_param = build_reasoning_param(
449
+ reasoning_effort,
450
+ reasoning_summary,
451
+ reasoning_overrides,
452
+ allowed_efforts=allowed_efforts_for_model(model),
453
+ )
454
+ service_tier, tier_error = _service_tier_from_payload(model, payload, verbose=verbose)
455
+ if tier_error is not None:
456
+ return tier_error
457
+ upstream, error_resp = start_upstream_request(
458
+ model,
459
+ input_items,
460
+ instructions=_instructions_for_model(model),
461
+ reasoning_param=reasoning_param,
462
+ service_tier=service_tier,
463
+ )
464
+ if error_resp is not None:
465
+ if verbose:
466
+ try:
467
+ body = error_resp.get_data(as_text=True)
468
+ if body:
469
+ try:
470
+ parsed = json.loads(body)
471
+ except Exception:
472
+ parsed = body
473
+ _log_json("OUT POST /v1/completions", parsed)
474
+ except Exception:
475
+ pass
476
+ return error_resp
477
+
478
+ record_rate_limits_from_response(upstream)
479
+
480
+ created = int(time.time())
481
+ if upstream.status_code >= 400:
482
+ try:
483
+ err_body = json.loads(upstream.content.decode("utf-8", errors="ignore")) if upstream.content else {"raw": upstream.text}
484
+ except Exception:
485
+ err_body = {"raw": upstream.text}
486
+ err = {"error": {"message": (err_body.get("error", {}) or {}).get("message", "Upstream error")}}
487
+ if verbose:
488
+ _log_json("OUT POST /v1/completions", err)
489
+ return jsonify(err), upstream.status_code
490
+
491
+ if stream_req:
492
+ if verbose:
493
+ print("OUT POST /v1/completions (streaming response)")
494
+ stream_iter = sse_translate_text(
495
+ upstream,
496
+ requested_model or model,
497
+ created,
498
+ verbose=verbose_obfuscation,
499
+ vlog=(print if verbose_obfuscation else None),
500
+ include_usage=include_usage,
501
+ )
502
+ stream_iter = _wrap_stream_logging("STREAM OUT /v1/completions", stream_iter, verbose)
503
+ resp = Response(
504
+ stream_iter,
505
+ status=upstream.status_code,
506
+ mimetype="text/event-stream",
507
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
508
+ )
509
+ for k, v in build_cors_headers().items():
510
+ resp.headers.setdefault(k, v)
511
+ return resp
512
+
513
+ full_text = ""
514
+ response_id = "cmpl"
515
+ usage_obj: Dict[str, int] | None = None
516
+ def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
517
+ try:
518
+ usage = (evt.get("response") or {}).get("usage")
519
+ if not isinstance(usage, dict):
520
+ return None
521
+ pt = int(usage.get("input_tokens") or 0)
522
+ ct = int(usage.get("output_tokens") or 0)
523
+ tt = int(usage.get("total_tokens") or (pt + ct))
524
+ return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
525
+ except Exception:
526
+ return None
527
+ try:
528
+ for raw_line in upstream.iter_lines(decode_unicode=False):
529
+ if not raw_line:
530
+ continue
531
+ line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
532
+ if not line.startswith("data: "):
533
+ continue
534
+ data = line[len("data: "):].strip()
535
+ if not data or data == "[DONE]":
536
+ if data == "[DONE]":
537
+ break
538
+ continue
539
+ try:
540
+ evt = json.loads(data)
541
+ except Exception:
542
+ continue
543
+ if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
544
+ response_id = evt["response"].get("id") or response_id
545
+ mu = _extract_usage(evt)
546
+ if mu:
547
+ usage_obj = mu
548
+ kind = evt.get("type")
549
+ if kind == "response.output_text.delta":
550
+ full_text += evt.get("delta") or ""
551
+ elif kind == "response.completed":
552
+ break
553
+ finally:
554
+ upstream.close()
555
+
556
+ completion = {
557
+ "id": response_id or "cmpl",
558
+ "object": "text_completion",
559
+ "created": created,
560
+ "model": requested_model or model,
561
+ "choices": [
562
+ {"index": 0, "text": full_text, "finish_reason": "stop", "logprobs": None}
563
+ ],
564
+ **({"usage": usage_obj} if usage_obj else {}),
565
+ }
566
+ if verbose:
567
+ _log_json("OUT POST /v1/completions", completion)
568
+ resp = make_response(jsonify(completion), upstream.status_code)
569
+ for k, v in build_cors_headers().items():
570
+ resp.headers.setdefault(k, v)
571
+ return resp
572
+
573
+
574
+ @openai_bp.route("/v1/responses", methods=["POST"])
575
+ def responses_create() -> Response:
576
+ verbose = bool(current_app.config.get("VERBOSE"))
577
+ raw = request.get_data(cache=True, as_text=True) or ""
578
+ if verbose:
579
+ try:
580
+ print("IN POST /v1/responses\n" + raw)
581
+ except Exception:
582
+ pass
583
+
584
+ try:
585
+ payload = json.loads(raw) if raw else {}
586
+ except Exception:
587
+ err = {"error": {"message": "Invalid JSON body"}}
588
+ if verbose:
589
+ _log_json("OUT POST /v1/responses", err)
590
+ return jsonify(err), 400
591
+
592
+ if not isinstance(payload, dict):
593
+ err = {"error": {"message": "Request body must be a JSON object"}}
594
+ if verbose:
595
+ _log_json("OUT POST /v1/responses", err)
596
+ return jsonify(err), 400
597
+
598
+ try:
599
+ normalized = normalize_responses_payload(
600
+ payload,
601
+ config=current_app.config,
602
+ client_session_id=extract_client_session_id(request.headers),
603
+ )
604
+ except ResponsesRequestError as exc:
605
+ err: Dict[str, Any] = {"error": {"message": str(exc)}}
606
+ if exc.code:
607
+ err["error"]["code"] = exc.code
608
+ if verbose:
609
+ _log_json("OUT POST /v1/responses", err)
610
+ return jsonify(err), exc.status_code
611
+
612
+ if normalized.service_tier_resolution.warning_message and verbose:
613
+ print(f"[FastMode] {normalized.service_tier_resolution.warning_message}")
614
+
615
+ prepared = prepare_responses_request_for_session(
616
+ normalized.session_id,
617
+ normalized.payload,
618
+ allow_previous_response_id=False,
619
+ )
620
+ stream_req = bool(prepared.payload.get("stream", False))
621
+ upstream_payload = dict(prepared.payload)
622
+ upstream_payload["stream"] = True
623
+ upstream, error_resp = start_upstream_raw_request(
624
+ upstream_payload,
625
+ session_id=normalized.session_id,
626
+ stream=True,
627
+ )
628
+ if error_resp is not None:
629
+ clear_responses_reuse_state(normalized.session_id)
630
+ if verbose:
631
+ try:
632
+ body = error_resp.get_data(as_text=True)
633
+ if body:
634
+ try:
635
+ parsed = json.loads(body)
636
+ except Exception:
637
+ parsed = body
638
+ _log_json("OUT POST /v1/responses", parsed)
639
+ except Exception:
640
+ pass
641
+ return error_resp
642
+
643
+ record_rate_limits_from_response(upstream)
644
+
645
+ if upstream.status_code >= 400:
646
+ try:
647
+ err_body = json.loads(upstream.content.decode("utf-8", errors="ignore")) if upstream.content else {"error": {"message": upstream.text}}
648
+ except Exception:
649
+ err_body = {"error": {"message": upstream.text or "Upstream error"}}
650
+ finally:
651
+ upstream.close()
652
+ clear_responses_reuse_state(normalized.session_id)
653
+ if verbose:
654
+ _log_json("OUT POST /v1/responses", err_body)
655
+ resp = make_response(jsonify(err_body), upstream.status_code)
656
+ for k, v in build_cors_headers().items():
657
+ resp.headers.setdefault(k, v)
658
+ return resp
659
+
660
+ if stream_req:
661
+ if verbose:
662
+ print("OUT POST /v1/responses (streaming response)")
663
+ stream_iter = _wrap_stream_logging(
664
+ "STREAM OUT /v1/responses",
665
+ stream_upstream_bytes(
666
+ upstream,
667
+ on_event=lambda evt: note_responses_stream_event(normalized.session_id, evt),
668
+ ),
669
+ verbose,
670
+ )
671
+ resp = Response(
672
+ stream_iter,
673
+ status=upstream.status_code,
674
+ mimetype="text/event-stream",
675
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
676
+ )
677
+ for k, v in build_cors_headers().items():
678
+ resp.headers.setdefault(k, v)
679
+ return resp
680
+
681
+ content_type = upstream.headers.get("Content-Type", "")
682
+ if "application/json" in content_type.lower():
683
+ try:
684
+ body = upstream.json()
685
+ except Exception:
686
+ body = None
687
+ finally:
688
+ upstream.close()
689
+ if isinstance(body, dict):
690
+ note_responses_final_response(normalized.session_id, body)
691
+ if verbose:
692
+ _log_json("OUT POST /v1/responses", body)
693
+ resp = make_response(jsonify(body), upstream.status_code)
694
+ for k, v in build_cors_headers().items():
695
+ resp.headers.setdefault(k, v)
696
+ return resp
697
+
698
+ response_obj, error_obj = aggregate_response_from_sse(
699
+ upstream,
700
+ on_event=lambda evt: note_responses_stream_event(normalized.session_id, evt),
701
+ )
702
+ if error_obj is not None:
703
+ clear_responses_reuse_state(normalized.session_id)
704
+ if verbose:
705
+ _log_json("OUT POST /v1/responses", error_obj)
706
+ resp = make_response(jsonify(error_obj), 502)
707
+ for k, v in build_cors_headers().items():
708
+ resp.headers.setdefault(k, v)
709
+ return resp
710
+
711
+ if response_obj is None:
712
+ clear_responses_reuse_state(normalized.session_id)
713
+ err = {"error": {"message": "Upstream response stream did not contain a completed response object"}}
714
+ if verbose:
715
+ _log_json("OUT POST /v1/responses", err)
716
+ resp = make_response(jsonify(err), 502)
717
+ for k, v in build_cors_headers().items():
718
+ resp.headers.setdefault(k, v)
719
+ return resp
720
+
721
+ if verbose:
722
+ _log_json("OUT POST /v1/responses", response_obj)
723
+ resp = make_response(jsonify(response_obj), upstream.status_code)
724
+ for k, v in build_cors_headers().items():
725
+ resp.headers.setdefault(k, v)
726
+ return resp
727
+
728
+
729
+ @openai_bp.route("/v1/models", methods=["GET"])
730
+ def list_models() -> Response:
731
+ expose_variants = bool(current_app.config.get("EXPOSE_REASONING_MODELS"))
732
+ model_ids = list_public_models(expose_reasoning_models=expose_variants)
733
+ data = [{"id": mid, "object": "model", "owned_by": "owner"} for mid in model_ids]
734
+ models = {"object": "list", "data": data}
735
+ resp = make_response(jsonify(models), 200)
736
+ for k, v in build_cors_headers().items():
737
+ resp.headers.setdefault(k, v)
738
+ return resp
build/lib/chatmock/session.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import threading
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List
10
+
11
+
12
+ _LOCK = threading.Lock()
13
+ _FINGERPRINT_TO_UUID: Dict[str, str] = {}
14
+ _ORDER: List[str] = []
15
+ _MAX_ENTRIES = 10000
16
+ _RESPONSES_SESSION_STATE: Dict[str, "_ResponsesSessionState"] = {}
17
+ _RESPONSES_ORDER: List[str] = []
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PreparedResponsesRequest:
22
+ payload: Dict[str, Any]
23
+ session_id: str
24
+
25
+
26
+ @dataclass
27
+ class _ResponsesSessionState:
28
+ last_request_payload: Dict[str, Any] | None = None
29
+ last_response_id: str | None = None
30
+ last_response_items: List[Dict[str, Any]] = field(default_factory=list)
31
+ inflight_request_payload: Dict[str, Any] | None = None
32
+ inflight_track_result: bool = False
33
+ inflight_response_id: str | None = None
34
+ inflight_response_items: List[Dict[str, Any]] = field(default_factory=list)
35
+
36
+
37
+ def _canonicalize_first_user_message(input_items: List[Dict[str, Any]]) -> Dict[str, Any] | None:
38
+ """
39
+ Extract the first stable user message from Responses input items. Good use for a fingerprint for prompt caching.
40
+ """
41
+ for item in input_items:
42
+ if not isinstance(item, dict):
43
+ continue
44
+ if item.get("type") != "message":
45
+ continue
46
+ role = item.get("role")
47
+ if role != "user":
48
+ continue
49
+ content = item.get("content")
50
+ if not isinstance(content, list):
51
+ continue
52
+ norm_content = []
53
+ for part in content:
54
+ if not isinstance(part, dict):
55
+ continue
56
+ ptype = part.get("type")
57
+ if ptype == "input_text":
58
+ text = part.get("text") if isinstance(part.get("text"), str) else ""
59
+ if text:
60
+ norm_content.append({"type": "input_text", "text": text})
61
+ elif ptype == "input_image":
62
+ url = part.get("image_url") if isinstance(part.get("image_url"), str) else None
63
+ if url:
64
+ norm_content.append({"type": "input_image", "image_url": url})
65
+ if norm_content:
66
+ return {"type": "message", "role": "user", "content": norm_content}
67
+ return None
68
+
69
+
70
+ def canonicalize_prefix(instructions: str | None, input_items: List[Dict[str, Any]]) -> str:
71
+ prefix: Dict[str, Any] = {}
72
+ if isinstance(instructions, str) and instructions.strip():
73
+ prefix["instructions"] = instructions.strip()
74
+ first_user = _canonicalize_first_user_message(input_items)
75
+ if first_user is not None:
76
+ prefix["first_user_message"] = first_user
77
+ return json.dumps(prefix, sort_keys=True, separators=(",", ":"))
78
+
79
+
80
+ def _fingerprint(s: str) -> str:
81
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
82
+
83
+
84
+ def _remember(fp: str, sid: str) -> None:
85
+ if fp in _FINGERPRINT_TO_UUID:
86
+ return
87
+ _FINGERPRINT_TO_UUID[fp] = sid
88
+ _ORDER.append(fp)
89
+ if len(_ORDER) > _MAX_ENTRIES:
90
+ oldest = _ORDER.pop(0)
91
+ _FINGERPRINT_TO_UUID.pop(oldest, None)
92
+
93
+
94
+ def _remember_responses_session(session_id: str) -> _ResponsesSessionState:
95
+ state = _RESPONSES_SESSION_STATE.get(session_id)
96
+ if state is None:
97
+ state = _ResponsesSessionState()
98
+ _RESPONSES_SESSION_STATE[session_id] = state
99
+ _RESPONSES_ORDER.append(session_id)
100
+ if len(_RESPONSES_ORDER) > _MAX_ENTRIES:
101
+ oldest = _RESPONSES_ORDER.pop(0)
102
+ _RESPONSES_SESSION_STATE.pop(oldest, None)
103
+ return state
104
+
105
+
106
+ def _request_without_input(payload: Dict[str, Any]) -> Dict[str, Any]:
107
+ clone = copy.deepcopy(payload)
108
+ clone["input"] = []
109
+ clone.pop("previous_response_id", None)
110
+ return clone
111
+
112
+
113
+ def _input_list(payload: Dict[str, Any]) -> List[Dict[str, Any]] | None:
114
+ raw = payload.get("input")
115
+ if not isinstance(raw, list):
116
+ return None
117
+ return [item for item in copy.deepcopy(raw) if isinstance(item, dict)]
118
+
119
+
120
+ def _conversation_output_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
121
+ reusable: List[Dict[str, Any]] = []
122
+ for item in items:
123
+ if not isinstance(item, dict):
124
+ continue
125
+ item_type = item.get("type")
126
+ if item_type == "reasoning":
127
+ continue
128
+ reusable.append(copy.deepcopy(item))
129
+ return reusable
130
+
131
+
132
+ def _clear_reuse_state(state: _ResponsesSessionState) -> None:
133
+ state.last_request_payload = None
134
+ state.last_response_id = None
135
+ state.last_response_items = []
136
+ state.inflight_request_payload = None
137
+ state.inflight_track_result = False
138
+ state.inflight_response_id = None
139
+ state.inflight_response_items = []
140
+
141
+
142
+ def _clear_inflight(state: _ResponsesSessionState) -> None:
143
+ state.inflight_request_payload = None
144
+ state.inflight_track_result = False
145
+ state.inflight_response_id = None
146
+ state.inflight_response_items = []
147
+
148
+
149
+ def ensure_session_id(
150
+ instructions: str | None,
151
+ input_items: List[Dict[str, Any]],
152
+ client_supplied: str | None = None,
153
+ ) -> str:
154
+ if isinstance(client_supplied, str) and client_supplied.strip():
155
+ return client_supplied.strip()
156
+
157
+ canon = canonicalize_prefix(instructions, input_items)
158
+ fp = _fingerprint(canon)
159
+ with _LOCK:
160
+ if fp in _FINGERPRINT_TO_UUID:
161
+ return _FINGERPRINT_TO_UUID[fp]
162
+ sid = str(uuid.uuid4())
163
+ _remember(fp, sid)
164
+ return sid
165
+
166
+
167
+ def prepare_responses_request_for_session(
168
+ session_id: str,
169
+ payload: Dict[str, Any],
170
+ *,
171
+ allow_previous_response_id: bool = True,
172
+ ) -> PreparedResponsesRequest:
173
+ full_payload = copy.deepcopy(payload)
174
+ outbound_payload = copy.deepcopy(payload)
175
+ explicit_previous_response_id = (
176
+ isinstance(full_payload.get("previous_response_id"), str)
177
+ and bool(full_payload.get("previous_response_id").strip())
178
+ )
179
+
180
+ with _LOCK:
181
+ state = _remember_responses_session(session_id)
182
+
183
+ if explicit_previous_response_id:
184
+ _clear_reuse_state(state)
185
+ return PreparedResponsesRequest(
186
+ payload=outbound_payload,
187
+ session_id=session_id,
188
+ )
189
+
190
+ request_input = _input_list(full_payload)
191
+ if (
192
+ allow_previous_response_id
193
+ and
194
+ state.last_request_payload is not None
195
+ and state.last_response_id
196
+ and request_input is not None
197
+ and _request_without_input(state.last_request_payload) == _request_without_input(full_payload)
198
+ ):
199
+ baseline: List[Dict[str, Any]] = []
200
+ previous_input = _input_list(state.last_request_payload)
201
+ if previous_input is not None:
202
+ baseline.extend(previous_input)
203
+ baseline.extend(copy.deepcopy(state.last_response_items))
204
+ baseline_len = len(baseline)
205
+ if request_input[:baseline_len] == baseline and baseline_len <= len(request_input):
206
+ outbound_payload["input"] = copy.deepcopy(request_input[baseline_len:])
207
+ outbound_payload["previous_response_id"] = state.last_response_id
208
+
209
+ state.inflight_request_payload = full_payload
210
+ state.inflight_track_result = True
211
+ state.inflight_response_id = None
212
+ state.inflight_response_items = []
213
+
214
+ return PreparedResponsesRequest(
215
+ payload=outbound_payload,
216
+ session_id=session_id,
217
+ )
218
+
219
+
220
+ def note_responses_stream_event(session_id: str, event: Dict[str, Any]) -> None:
221
+ if not isinstance(session_id, str) or not session_id.strip():
222
+ return
223
+ if not isinstance(event, dict):
224
+ return
225
+
226
+ with _LOCK:
227
+ state = _RESPONSES_SESSION_STATE.get(session_id)
228
+ if state is None:
229
+ return
230
+
231
+ kind = event.get("type")
232
+ if kind == "response.created":
233
+ response = event.get("response")
234
+ if isinstance(response, dict) and isinstance(response.get("id"), str):
235
+ state.inflight_response_id = response.get("id")
236
+ return
237
+
238
+ if kind == "response.output_item.done":
239
+ item = event.get("item")
240
+ if isinstance(item, dict):
241
+ state.inflight_response_items.append(copy.deepcopy(item))
242
+ return
243
+
244
+ if kind == "response.completed":
245
+ response = event.get("response")
246
+ response_id = None
247
+ response_items: List[Dict[str, Any]] = copy.deepcopy(state.inflight_response_items)
248
+ if isinstance(response, dict):
249
+ if isinstance(response.get("id"), str):
250
+ response_id = response.get("id")
251
+ output = response.get("output")
252
+ if isinstance(output, list) and output:
253
+ response_items = [copy.deepcopy(item) for item in output if isinstance(item, dict)]
254
+ if not response_id:
255
+ response_id = state.inflight_response_id
256
+
257
+ if state.inflight_track_result and state.inflight_request_payload is not None and response_id:
258
+ state.last_request_payload = copy.deepcopy(state.inflight_request_payload)
259
+ state.last_response_id = response_id
260
+ state.last_response_items = _conversation_output_items(response_items)
261
+ else:
262
+ state.last_request_payload = None
263
+ state.last_response_id = None
264
+ state.last_response_items = []
265
+ _clear_inflight(state)
266
+ return
267
+
268
+ if kind in ("response.failed", "error"):
269
+ _clear_reuse_state(state)
270
+
271
+
272
+ def note_responses_final_response(session_id: str, response_obj: Dict[str, Any]) -> None:
273
+ if not isinstance(session_id, str) or not session_id.strip():
274
+ return
275
+ if not isinstance(response_obj, dict):
276
+ return
277
+
278
+ with _LOCK:
279
+ state = _RESPONSES_SESSION_STATE.get(session_id)
280
+ if state is None:
281
+ return
282
+
283
+ response_id = response_obj.get("id") if isinstance(response_obj.get("id"), str) else None
284
+ output = response_obj.get("output")
285
+ output_items = [copy.deepcopy(item) for item in output if isinstance(item, dict)] if isinstance(output, list) else []
286
+ if state.inflight_track_result and state.inflight_request_payload is not None and response_id:
287
+ state.last_request_payload = copy.deepcopy(state.inflight_request_payload)
288
+ state.last_response_id = response_id
289
+ state.last_response_items = _conversation_output_items(output_items)
290
+ else:
291
+ state.last_request_payload = None
292
+ state.last_response_id = None
293
+ state.last_response_items = []
294
+ _clear_inflight(state)
295
+
296
+
297
+ def clear_responses_reuse_state(session_id: str) -> None:
298
+ if not isinstance(session_id, str) or not session_id.strip():
299
+ return
300
+ with _LOCK:
301
+ state = _RESPONSES_SESSION_STATE.get(session_id)
302
+ if state is None:
303
+ return
304
+ _clear_reuse_state(state)
305
+
306
+
307
+ def reset_session_state() -> None:
308
+ with _LOCK:
309
+ _FINGERPRINT_TO_UUID.clear()
310
+ _ORDER.clear()
311
+ _RESPONSES_SESSION_STATE.clear()
312
+ _RESPONSES_ORDER.clear()
build/lib/chatmock/transform.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Dict, List
5
+
6
+
7
+ def to_data_url(image_str: str) -> str:
8
+ if not isinstance(image_str, str) or not image_str:
9
+ return image_str
10
+ s = image_str.strip()
11
+ if s.startswith("data:image/"):
12
+ return s
13
+ if s.startswith("http://") or s.startswith("https://"):
14
+ return s
15
+ b64 = s.replace("\n", "").replace("\r", "")
16
+ kind = "image/png"
17
+ if b64.startswith("/9j/"):
18
+ kind = "image/jpeg"
19
+ elif b64.startswith("iVBORw0KGgo"):
20
+ kind = "image/png"
21
+ elif b64.startswith("R0lGOD"):
22
+ kind = "image/gif"
23
+ return f"data:{kind};base64,{b64}"
24
+
25
+
26
+ def convert_ollama_messages(
27
+ messages: List[Dict[str, Any]] | None, top_images: List[str] | None
28
+ ) -> List[Dict[str, Any]]:
29
+ out: List[Dict[str, Any]] = []
30
+ msgs = messages if isinstance(messages, list) else []
31
+ pending_call_ids: List[str] = []
32
+ call_counter = 0
33
+ for m in msgs:
34
+ if not isinstance(m, dict):
35
+ continue
36
+ role = m.get("role") or "user"
37
+ nm: Dict[str, Any] = {"role": role}
38
+
39
+ content = m.get("content")
40
+ images = m.get("images") if isinstance(m.get("images"), list) else []
41
+ parts: List[Dict[str, Any]] = []
42
+ if isinstance(content, list):
43
+ for p in content:
44
+ if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str):
45
+ parts.append({"type": "text", "text": p.get("text")})
46
+ elif isinstance(content, str):
47
+ parts.append({"type": "text", "text": content})
48
+ for img in images:
49
+ url = to_data_url(img)
50
+ if isinstance(url, str) and url:
51
+ parts.append({"type": "image_url", "image_url": {"url": url}})
52
+ if parts:
53
+ nm["content"] = parts
54
+
55
+ if role == "assistant" and isinstance(m.get("tool_calls"), list):
56
+ tcs = []
57
+ for tc in m.get("tool_calls"):
58
+ if not isinstance(tc, dict):
59
+ continue
60
+ fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
61
+ name = fn.get("name") if isinstance(fn.get("name"), str) else None
62
+ args = fn.get("arguments")
63
+ if name is None:
64
+ continue
65
+ call_id = tc.get("id") or tc.get("call_id")
66
+ if not isinstance(call_id, str) or not call_id:
67
+ call_counter += 1
68
+ call_id = f"ollama_call_{call_counter}"
69
+ pending_call_ids.append(call_id)
70
+ tcs.append(
71
+ {
72
+ "id": call_id,
73
+ "type": "function",
74
+ "function": {
75
+ "name": name,
76
+ "arguments": args if isinstance(args, str) else (json.dumps(args) if isinstance(args, dict) else "{}"),
77
+ },
78
+ }
79
+ )
80
+ if tcs:
81
+ nm["tool_calls"] = tcs
82
+
83
+ if role == "tool":
84
+ tci = m.get("tool_call_id") or m.get("id")
85
+ if not isinstance(tci, str) or not tci:
86
+ if pending_call_ids:
87
+ tci = pending_call_ids.pop(0)
88
+ if isinstance(tci, str) and tci:
89
+ nm["tool_call_id"] = tci
90
+
91
+ if not parts and isinstance(content, str):
92
+ nm["content"] = content
93
+
94
+ out.append(nm)
95
+
96
+ if isinstance(top_images, list) and top_images:
97
+ attach_to = None
98
+ for i in range(len(out) - 1, -1, -1):
99
+ if out[i].get("role") == "user":
100
+ attach_to = out[i]
101
+ break
102
+ if attach_to is None:
103
+ attach_to = {"role": "user", "content": []}
104
+ out.append(attach_to)
105
+ attach_to.setdefault("content", [])
106
+ for img in top_images:
107
+ url = to_data_url(img)
108
+ if isinstance(url, str) and url:
109
+ attach_to["content"].append({"type": "image_url", "image_url": {"url": url}})
110
+ return out
111
+
112
+
113
+ def normalize_ollama_tools(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]]:
114
+ out: List[Dict[str, Any]] = []
115
+ if not isinstance(tools, list):
116
+ return out
117
+ for t in tools:
118
+ if not isinstance(t, dict):
119
+ continue
120
+ if isinstance(t.get("function"), dict):
121
+ fn = t.get("function")
122
+ name = fn.get("name") if isinstance(fn.get("name"), str) else None
123
+ if not name:
124
+ continue
125
+ out.append(
126
+ {
127
+ "type": "function",
128
+ "function": {
129
+ "name": name,
130
+ "description": fn.get("description") or "",
131
+ "parameters": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object", "properties": {}},
132
+ },
133
+ }
134
+ )
135
+ continue
136
+ name = t.get("name") if isinstance(t.get("name"), str) else None
137
+ if name:
138
+ out.append(
139
+ {
140
+ "type": "function",
141
+ "function": {
142
+ "name": name,
143
+ "description": t.get("description") or "",
144
+ "parameters": {"type": "object", "properties": {}},
145
+ },
146
+ }
147
+ )
148
+ return out
149
+
build/lib/chatmock/upstream.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from typing import Any, Dict, List, Tuple
6
+ from urllib.parse import urlparse, urlunparse
7
+
8
+ import requests
9
+ from flask import Response, current_app, jsonify, make_response
10
+
11
+ from .config import CHATGPT_RESPONSES_URL
12
+ from .http import build_cors_headers
13
+ from .model_registry import normalize_model_name
14
+ from .session import ensure_session_id
15
+ from flask import request as flask_request
16
+ from .utils import get_effective_chatgpt_auth
17
+
18
+
19
+ def _log_json(prefix: str, payload: Any) -> None:
20
+ try:
21
+ print(f"{prefix}\n{json.dumps(payload, indent=2, ensure_ascii=False)}")
22
+ except Exception:
23
+ try:
24
+ print(f"{prefix}\n{payload}")
25
+ except Exception:
26
+ pass
27
+
28
+ def start_upstream_request(
29
+ model: str,
30
+ input_items: List[Dict[str, Any]],
31
+ *,
32
+ instructions: str | None = None,
33
+ tools: List[Dict[str, Any]] | None = None,
34
+ tool_choice: Any | None = None,
35
+ parallel_tool_calls: bool = False,
36
+ reasoning_param: Dict[str, Any] | None = None,
37
+ service_tier: str | None = None,
38
+ ):
39
+ access_token, account_id = get_effective_chatgpt_auth()
40
+ if not access_token or not account_id:
41
+ resp = make_response(
42
+ jsonify(
43
+ {
44
+ "error": {
45
+ "message": "Missing ChatGPT credentials. Run 'python3 chatmock.py login' first.",
46
+ }
47
+ }
48
+ ),
49
+ 401,
50
+ )
51
+ for k, v in build_cors_headers().items():
52
+ resp.headers.setdefault(k, v)
53
+ return None, resp
54
+
55
+ include: List[str] = []
56
+ if isinstance(reasoning_param, dict):
57
+ include.append("reasoning.encrypted_content")
58
+
59
+ client_session_id = None
60
+ try:
61
+ client_session_id = (
62
+ flask_request.headers.get("X-Session-Id")
63
+ or flask_request.headers.get("session_id")
64
+ or None
65
+ )
66
+ except Exception:
67
+ client_session_id = None
68
+ session_id = ensure_session_id(instructions, input_items, client_session_id)
69
+
70
+ responses_payload = {
71
+ "model": model,
72
+ "instructions": instructions if isinstance(instructions, str) and instructions.strip() else instructions,
73
+ "input": input_items,
74
+ "tools": tools or [],
75
+ "tool_choice": tool_choice if tool_choice in ("auto", "none") or isinstance(tool_choice, dict) else "auto",
76
+ "parallel_tool_calls": bool(parallel_tool_calls),
77
+ "store": False,
78
+ "stream": True,
79
+ "prompt_cache_key": session_id,
80
+ }
81
+ if include:
82
+ responses_payload["include"] = include
83
+
84
+ if reasoning_param is not None:
85
+ responses_payload["reasoning"] = reasoning_param
86
+ if isinstance(service_tier, str) and service_tier.strip():
87
+ responses_payload["service_tier"] = service_tier.strip().lower()
88
+
89
+ return start_upstream_raw_request(
90
+ responses_payload,
91
+ session_id=session_id,
92
+ stream=True,
93
+ )
94
+
95
+
96
+ def build_upstream_headers(
97
+ access_token: str,
98
+ account_id: str,
99
+ session_id: str,
100
+ *,
101
+ accept: str = "text/event-stream",
102
+ ) -> Dict[str, str]:
103
+ return {
104
+ "Authorization": f"Bearer {access_token}",
105
+ "Content-Type": "application/json",
106
+ "Accept": accept,
107
+ "chatgpt-account-id": account_id,
108
+ "OpenAI-Beta": "responses=experimental",
109
+ "session_id": session_id,
110
+ }
111
+
112
+
113
+ def start_upstream_raw_request(
114
+ responses_payload: Dict[str, Any],
115
+ *,
116
+ session_id: str | None = None,
117
+ stream: bool = True,
118
+ ):
119
+ access_token, account_id = get_effective_chatgpt_auth()
120
+ if not access_token or not account_id:
121
+ resp = make_response(
122
+ jsonify(
123
+ {
124
+ "error": {
125
+ "message": "Missing ChatGPT credentials. Run 'python3 chatmock.py login' first.",
126
+ }
127
+ }
128
+ ),
129
+ 401,
130
+ )
131
+ for k, v in build_cors_headers().items():
132
+ resp.headers.setdefault(k, v)
133
+ return None, resp
134
+
135
+ effective_session_id = session_id
136
+ if not isinstance(effective_session_id, str) or not effective_session_id.strip():
137
+ payload_prompt_cache_key = responses_payload.get("prompt_cache_key")
138
+ if isinstance(payload_prompt_cache_key, str) and payload_prompt_cache_key.strip():
139
+ effective_session_id = payload_prompt_cache_key.strip()
140
+ if not isinstance(effective_session_id, str) or not effective_session_id.strip():
141
+ effective_session_id = str(int(time.time() * 1000))
142
+
143
+ verbose = False
144
+ try:
145
+ verbose = bool(current_app.config.get("VERBOSE"))
146
+ except Exception:
147
+ verbose = False
148
+ if verbose:
149
+ _log_json("OUTBOUND >> ChatGPT Responses API payload", responses_payload)
150
+
151
+ headers = build_upstream_headers(
152
+ access_token,
153
+ account_id,
154
+ effective_session_id,
155
+ accept=("text/event-stream" if stream else "application/json"),
156
+ )
157
+
158
+ try:
159
+ upstream = requests.post(
160
+ CHATGPT_RESPONSES_URL,
161
+ headers=headers,
162
+ json=responses_payload,
163
+ stream=stream,
164
+ timeout=600,
165
+ )
166
+ except requests.RequestException as e:
167
+ resp = make_response(jsonify({"error": {"message": f"Upstream ChatGPT request failed: {e}"}}), 502)
168
+ for k, v in build_cors_headers().items():
169
+ resp.headers.setdefault(k, v)
170
+ return None, resp
171
+ return upstream, None
172
+
173
+
174
+ def build_upstream_websocket_url() -> str:
175
+ parsed = urlparse(CHATGPT_RESPONSES_URL)
176
+ scheme = parsed.scheme.lower()
177
+ if scheme == "https":
178
+ parsed = parsed._replace(scheme="wss")
179
+ elif scheme == "http":
180
+ parsed = parsed._replace(scheme="ws")
181
+ return urlunparse(parsed)
build/lib/chatmock/utils.py ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import datetime
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import secrets
9
+ import sys
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ import requests
13
+
14
+ from .config import CLIENT_ID_DEFAULT, OAUTH_TOKEN_URL
15
+
16
+
17
+ def eprint(*args, **kwargs) -> None:
18
+ print(*args, file=sys.stderr, **kwargs)
19
+
20
+
21
+ def get_home_dir() -> str:
22
+ home = os.getenv("CHATGPT_LOCAL_HOME") or os.getenv("CODEX_HOME")
23
+ if not home:
24
+ home = os.path.expanduser("~/.chatgpt-local")
25
+ return home
26
+
27
+
28
+ def read_auth_file() -> Dict[str, Any] | None:
29
+ for base in [
30
+ os.getenv("CHATGPT_LOCAL_HOME"),
31
+ os.getenv("CODEX_HOME"),
32
+ os.path.expanduser("~/.chatgpt-local"),
33
+ os.path.expanduser("~/.codex"),
34
+ ]:
35
+ if not base:
36
+ continue
37
+ path = os.path.join(base, "auth.json")
38
+ try:
39
+ with open(path, "r", encoding="utf-8") as f:
40
+ return json.load(f)
41
+ except FileNotFoundError:
42
+ continue
43
+ except Exception:
44
+ continue
45
+ return None
46
+
47
+
48
+ def write_auth_file(auth: Dict[str, Any]) -> bool:
49
+ home = get_home_dir()
50
+ try:
51
+ os.makedirs(home, exist_ok=True)
52
+ except Exception as exc:
53
+ eprint(f"ERROR: unable to create auth home directory {home}: {exc}")
54
+ return False
55
+ path = os.path.join(home, "auth.json")
56
+ try:
57
+ with open(path, "w", encoding="utf-8") as fp:
58
+ if hasattr(os, "fchmod"):
59
+ os.fchmod(fp.fileno(), 0o600)
60
+ json.dump(auth, fp, indent=2)
61
+ return True
62
+ except Exception as exc:
63
+ eprint(f"ERROR: unable to write auth file: {exc}")
64
+ return False
65
+
66
+
67
+ def parse_jwt_claims(token: str) -> Dict[str, Any] | None:
68
+ if not token or token.count(".") != 2:
69
+ return None
70
+ try:
71
+ _, payload, _ = token.split(".")
72
+ padded = payload + "=" * (-len(payload) % 4)
73
+ data = base64.urlsafe_b64decode(padded.encode())
74
+ return json.loads(data.decode())
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def generate_pkce() -> "PkceCodes":
80
+ from .models import PkceCodes
81
+
82
+ code_verifier = secrets.token_hex(64)
83
+ digest = hashlib.sha256(code_verifier.encode()).digest()
84
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
85
+ return PkceCodes(code_verifier=code_verifier, code_challenge=code_challenge)
86
+
87
+
88
+ def convert_chat_messages_to_responses_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
89
+ def _normalize_image_data_url(url: str) -> str:
90
+ try:
91
+ if not isinstance(url, str):
92
+ return url
93
+ if not url.startswith("data:image/"):
94
+ return url
95
+ if ";base64," not in url:
96
+ return url
97
+ header, data = url.split(",", 1)
98
+ try:
99
+ from urllib.parse import unquote
100
+
101
+ data = unquote(data)
102
+ except Exception:
103
+ pass
104
+ data = data.strip().replace("\n", "").replace("\r", "")
105
+ data = data.replace("-", "+").replace("_", "/")
106
+ pad = (-len(data)) % 4
107
+ if pad:
108
+ data = data + ("=" * pad)
109
+ try:
110
+ base64.b64decode(data, validate=True)
111
+ except Exception:
112
+ return url
113
+ return f"{header},{data}"
114
+ except Exception:
115
+ return url
116
+
117
+ input_items: List[Dict[str, Any]] = []
118
+ for message in messages:
119
+ role = message.get("role")
120
+ if role == "system":
121
+ continue
122
+
123
+ if role == "tool":
124
+ call_id = message.get("tool_call_id") or message.get("id")
125
+ if isinstance(call_id, str) and call_id:
126
+ content = message.get("content", "")
127
+ if isinstance(content, list):
128
+ texts = []
129
+ for part in content:
130
+ if isinstance(part, dict):
131
+ t = part.get("text") or part.get("content")
132
+ if isinstance(t, str) and t:
133
+ texts.append(t)
134
+ content = "\n".join(texts)
135
+ if isinstance(content, str):
136
+ input_items.append(
137
+ {
138
+ "type": "function_call_output",
139
+ "call_id": call_id,
140
+ "output": content,
141
+ }
142
+ )
143
+ continue
144
+ if role == "assistant" and isinstance(message.get("tool_calls"), list):
145
+ for tc in message.get("tool_calls") or []:
146
+ if not isinstance(tc, dict):
147
+ continue
148
+ tc_type = tc.get("type", "function")
149
+ if tc_type != "function":
150
+ continue
151
+ call_id = tc.get("id") or tc.get("call_id")
152
+ fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
153
+ name = fn.get("name") if isinstance(fn, dict) else None
154
+ args = fn.get("arguments") if isinstance(fn, dict) else None
155
+ if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
156
+ input_items.append(
157
+ {
158
+ "type": "function_call",
159
+ "name": name,
160
+ "arguments": args,
161
+ "call_id": call_id,
162
+ }
163
+ )
164
+
165
+ content = message.get("content", "")
166
+ content_items: List[Dict[str, Any]] = []
167
+ if isinstance(content, list):
168
+ for part in content:
169
+ if not isinstance(part, dict):
170
+ continue
171
+ ptype = part.get("type")
172
+ if ptype == "text":
173
+ text = part.get("text") or part.get("content") or ""
174
+ if isinstance(text, str) and text:
175
+ kind = "output_text" if role == "assistant" else "input_text"
176
+ content_items.append({"type": kind, "text": text})
177
+ elif ptype == "image_url":
178
+ image = part.get("image_url")
179
+ url = image.get("url") if isinstance(image, dict) else image
180
+ if isinstance(url, str) and url:
181
+ content_items.append({"type": "input_image", "image_url": _normalize_image_data_url(url)})
182
+ elif isinstance(content, str) and content:
183
+ kind = "output_text" if role == "assistant" else "input_text"
184
+ content_items.append({"type": kind, "text": content})
185
+
186
+ if not content_items:
187
+ continue
188
+ role_out = "assistant" if role == "assistant" else "user"
189
+ input_items.append({"type": "message", "role": role_out, "content": content_items})
190
+ return input_items
191
+
192
+
193
+ def convert_tools_chat_to_responses(tools: Any) -> List[Dict[str, Any]]:
194
+ out: List[Dict[str, Any]] = []
195
+ if not isinstance(tools, list):
196
+ return out
197
+ for t in tools:
198
+ if not isinstance(t, dict):
199
+ continue
200
+ if t.get("type") != "function":
201
+ continue
202
+ fn = t.get("function") if isinstance(t.get("function"), dict) else {}
203
+ name = fn.get("name") if isinstance(fn, dict) else None
204
+ if not isinstance(name, str) or not name:
205
+ continue
206
+ desc = fn.get("description") if isinstance(fn, dict) else None
207
+ params = fn.get("parameters") if isinstance(fn, dict) else None
208
+ if not isinstance(params, dict):
209
+ params = {"type": "object", "properties": {}}
210
+ out.append(
211
+ {
212
+ "type": "function",
213
+ "name": name,
214
+ "description": desc or "",
215
+ "strict": False,
216
+ "parameters": params,
217
+ }
218
+ )
219
+ return out
220
+
221
+
222
+ def load_chatgpt_tokens(ensure_fresh: bool = True) -> tuple[str | None, str | None, str | None]:
223
+ auth = read_auth_file()
224
+ if not isinstance(auth, dict):
225
+ return None, None, None
226
+
227
+ tokens = auth.get("tokens") if isinstance(auth.get("tokens"), dict) else {}
228
+ access_token: Optional[str] = tokens.get("access_token")
229
+ account_id: Optional[str] = tokens.get("account_id")
230
+ id_token: Optional[str] = tokens.get("id_token")
231
+ refresh_token: Optional[str] = tokens.get("refresh_token")
232
+ last_refresh = auth.get("last_refresh")
233
+
234
+ if ensure_fresh and isinstance(refresh_token, str) and refresh_token and CLIENT_ID_DEFAULT:
235
+ needs_refresh = _should_refresh_access_token(access_token, last_refresh)
236
+ if needs_refresh or not (isinstance(access_token, str) and access_token):
237
+ refreshed = _refresh_chatgpt_tokens(refresh_token, CLIENT_ID_DEFAULT)
238
+ if refreshed:
239
+ access_token = refreshed.get("access_token") or access_token
240
+ id_token = refreshed.get("id_token") or id_token
241
+ refresh_token = refreshed.get("refresh_token") or refresh_token
242
+ account_id = refreshed.get("account_id") or account_id
243
+
244
+ updated_tokens = dict(tokens)
245
+ if isinstance(access_token, str) and access_token:
246
+ updated_tokens["access_token"] = access_token
247
+ if isinstance(id_token, str) and id_token:
248
+ updated_tokens["id_token"] = id_token
249
+ if isinstance(refresh_token, str) and refresh_token:
250
+ updated_tokens["refresh_token"] = refresh_token
251
+ if isinstance(account_id, str) and account_id:
252
+ updated_tokens["account_id"] = account_id
253
+
254
+ persisted = _persist_refreshed_auth(auth, updated_tokens)
255
+ if persisted is not None:
256
+ auth, tokens = persisted
257
+ else:
258
+ tokens = updated_tokens
259
+
260
+ if not isinstance(account_id, str) or not account_id:
261
+ account_id = _derive_account_id(id_token)
262
+
263
+ access_token = access_token if isinstance(access_token, str) and access_token else None
264
+ id_token = id_token if isinstance(id_token, str) and id_token else None
265
+ account_id = account_id if isinstance(account_id, str) and account_id else None
266
+ return access_token, account_id, id_token
267
+
268
+
269
+ def _should_refresh_access_token(access_token: Optional[str], last_refresh: Any) -> bool:
270
+ if not isinstance(access_token, str) or not access_token:
271
+ return True
272
+
273
+ claims = parse_jwt_claims(access_token) or {}
274
+ exp = claims.get("exp") if isinstance(claims, dict) else None
275
+ now = datetime.datetime.now(datetime.timezone.utc)
276
+ if isinstance(exp, (int, float)):
277
+ try:
278
+ expiry = datetime.datetime.fromtimestamp(float(exp), datetime.timezone.utc)
279
+ except (OverflowError, OSError, ValueError):
280
+ expiry = None
281
+ if expiry is not None:
282
+ return expiry <= now + datetime.timedelta(minutes=5)
283
+
284
+ if isinstance(last_refresh, str):
285
+ refreshed_at = _parse_iso8601(last_refresh)
286
+ if refreshed_at is not None:
287
+ return refreshed_at <= now - datetime.timedelta(minutes=55)
288
+ return False
289
+
290
+
291
+ def _refresh_chatgpt_tokens(refresh_token: str, client_id: str) -> Optional[Dict[str, Optional[str]]]:
292
+ payload = {
293
+ "grant_type": "refresh_token",
294
+ "refresh_token": refresh_token,
295
+ "client_id": client_id,
296
+ "scope": "openid profile email offline_access",
297
+ }
298
+
299
+ try:
300
+ resp = requests.post(OAUTH_TOKEN_URL, json=payload, timeout=30)
301
+ except requests.RequestException as exc:
302
+ eprint(f"ERROR: failed to refresh ChatGPT token: {exc}")
303
+ return None
304
+
305
+ if resp.status_code >= 400:
306
+ eprint(f"ERROR: refresh token request returned status {resp.status_code}")
307
+ return None
308
+
309
+ try:
310
+ data = resp.json()
311
+ except ValueError as exc:
312
+ eprint(f"ERROR: unable to parse refresh token response: {exc}")
313
+ return None
314
+
315
+ id_token = data.get("id_token")
316
+ access_token = data.get("access_token")
317
+ new_refresh_token = data.get("refresh_token") or refresh_token
318
+ if not isinstance(id_token, str) or not isinstance(access_token, str):
319
+ eprint("ERROR: refresh token response missing expected tokens")
320
+ return None
321
+
322
+ account_id = _derive_account_id(id_token)
323
+ new_refresh_token = new_refresh_token if isinstance(new_refresh_token, str) and new_refresh_token else refresh_token
324
+ return {
325
+ "id_token": id_token,
326
+ "access_token": access_token,
327
+ "refresh_token": new_refresh_token,
328
+ "account_id": account_id,
329
+ }
330
+
331
+
332
+ def _persist_refreshed_auth(auth: Dict[str, Any], updated_tokens: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
333
+ updated_auth = dict(auth)
334
+ updated_auth["tokens"] = updated_tokens
335
+ updated_auth["last_refresh"] = _now_iso8601()
336
+ if write_auth_file(updated_auth):
337
+ return updated_auth, updated_tokens
338
+ eprint("ERROR: unable to persist refreshed auth tokens")
339
+ return None
340
+
341
+
342
+ def _derive_account_id(id_token: Optional[str]) -> Optional[str]:
343
+ if not isinstance(id_token, str) or not id_token:
344
+ return None
345
+ claims = parse_jwt_claims(id_token) or {}
346
+ auth_claims = claims.get("https://api.openai.com/auth") if isinstance(claims, dict) else None
347
+ if isinstance(auth_claims, dict):
348
+ account_id = auth_claims.get("chatgpt_account_id")
349
+ if isinstance(account_id, str) and account_id:
350
+ return account_id
351
+ return None
352
+
353
+
354
+ def _parse_iso8601(value: str) -> Optional[datetime.datetime]:
355
+ try:
356
+ if value.endswith("Z"):
357
+ value = value[:-1] + "+00:00"
358
+ dt = datetime.datetime.fromisoformat(value)
359
+ if dt.tzinfo is None:
360
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
361
+ return dt.astimezone(datetime.timezone.utc)
362
+ except Exception:
363
+ return None
364
+
365
+
366
+ def _now_iso8601() -> str:
367
+ return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
368
+
369
+
370
+ def get_effective_chatgpt_auth() -> tuple[str | None, str | None]:
371
+ access_token, account_id, id_token = load_chatgpt_tokens()
372
+ if not account_id:
373
+ account_id = _derive_account_id(id_token)
374
+ return access_token, account_id
375
+
376
+
377
+ def sse_translate_chat(
378
+ upstream,
379
+ model: str,
380
+ created: int,
381
+ verbose: bool = False,
382
+ vlog=None,
383
+ reasoning_compat: str = "think-tags",
384
+ *,
385
+ include_usage: bool = False,
386
+ ):
387
+ response_id = "chatcmpl-stream"
388
+ compat = (reasoning_compat or "think-tags").strip().lower()
389
+ think_open = False
390
+ think_closed = False
391
+ saw_output = False
392
+ sent_stop_chunk = False
393
+ saw_any_summary = False
394
+ pending_summary_paragraph = False
395
+ upstream_usage = None
396
+ ws_state: dict[str, Any] = {}
397
+ ws_index: dict[str, int] = {}
398
+ ws_next_index: int = 0
399
+
400
+ def _serialize_tool_args(eff_args: Any) -> str:
401
+ """
402
+ Serialize tool call arguments with proper JSON handling.
403
+
404
+ Args:
405
+ eff_args: Arguments to serialize (dict, list, str, or other)
406
+
407
+ Returns:
408
+ JSON string representation of the arguments
409
+ """
410
+ if isinstance(eff_args, (dict, list)):
411
+ return json.dumps(eff_args)
412
+ elif isinstance(eff_args, str):
413
+ try:
414
+ parsed = json.loads(eff_args)
415
+ if isinstance(parsed, (dict, list)):
416
+ return json.dumps(parsed)
417
+ else:
418
+ return json.dumps({"query": eff_args})
419
+ except (json.JSONDecodeError, ValueError):
420
+ return json.dumps({"query": eff_args})
421
+ else:
422
+ return "{}"
423
+
424
+ def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
425
+ try:
426
+ usage = (evt.get("response") or {}).get("usage")
427
+ if not isinstance(usage, dict):
428
+ return None
429
+ pt = int(usage.get("input_tokens") or 0)
430
+ ct = int(usage.get("output_tokens") or 0)
431
+ tt = int(usage.get("total_tokens") or (pt + ct))
432
+ return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
433
+ except Exception:
434
+ return None
435
+ try:
436
+ try:
437
+ line_iterator = upstream.iter_lines(decode_unicode=False)
438
+ except requests.exceptions.ChunkedEncodingError as e:
439
+ if verbose and vlog:
440
+ vlog(f"Failed to start stream: {e}")
441
+ yield b"data: [DONE]\n\n"
442
+ return
443
+
444
+ for raw in line_iterator:
445
+ try:
446
+ if not raw:
447
+ continue
448
+ line = (
449
+ raw.decode("utf-8", errors="ignore")
450
+ if isinstance(raw, (bytes, bytearray))
451
+ else raw
452
+ )
453
+ if verbose and vlog:
454
+ vlog(line)
455
+ if not line.startswith("data: "):
456
+ continue
457
+ data = line[len("data: ") :].strip()
458
+ if not data:
459
+ continue
460
+ if data == "[DONE]":
461
+ break
462
+ try:
463
+ evt = json.loads(data)
464
+ except (json.JSONDecodeError, UnicodeDecodeError):
465
+ continue
466
+ except (
467
+ requests.exceptions.ChunkedEncodingError,
468
+ ConnectionError,
469
+ BrokenPipeError,
470
+ ) as e:
471
+ # Connection interrupted mid-stream - end gracefully
472
+ if verbose and vlog:
473
+ vlog(f"Stream interrupted: {e}")
474
+ yield b"data: [DONE]\n\n"
475
+ return
476
+ kind = evt.get("type")
477
+ if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
478
+ response_id = evt["response"].get("id") or response_id
479
+
480
+ if isinstance(kind, str) and ("web_search_call" in kind):
481
+ try:
482
+ call_id = evt.get("item_id") or "ws_call"
483
+ if verbose and vlog:
484
+ try:
485
+ vlog(f"CM_TOOLS {kind} id={call_id} -> tool_calls(web_search)")
486
+ except Exception:
487
+ pass
488
+ item = evt.get('item') if isinstance(evt.get('item'), dict) else {}
489
+ params_dict = ws_state.setdefault(call_id, {}) if isinstance(ws_state.get(call_id), dict) else {}
490
+ def _merge_from(src):
491
+ if not isinstance(src, dict):
492
+ return
493
+ for whole in ('parameters','args','arguments','input'):
494
+ if isinstance(src.get(whole), dict):
495
+ params_dict.update(src.get(whole))
496
+ if isinstance(src.get('query'), str): params_dict.setdefault('query', src.get('query'))
497
+ if isinstance(src.get('q'), str): params_dict.setdefault('query', src.get('q'))
498
+ for rk in ('recency','time_range','days'):
499
+ if src.get(rk) is not None and rk not in params_dict: params_dict[rk] = src.get(rk)
500
+ for dk in ('domains','include_domains','include'):
501
+ if isinstance(src.get(dk), list) and 'domains' not in params_dict: params_dict['domains'] = src.get(dk)
502
+ for mk in ('max_results','topn','limit'):
503
+ if src.get(mk) is not None and 'max_results' not in params_dict: params_dict['max_results'] = src.get(mk)
504
+ _merge_from(item)
505
+ _merge_from(evt if isinstance(evt, dict) else None)
506
+ params = params_dict if params_dict else None
507
+ if isinstance(params, dict):
508
+ try:
509
+ ws_state.setdefault(call_id, {}).update(params)
510
+ except Exception:
511
+ pass
512
+ eff_params = ws_state.get(call_id, params if isinstance(params, (dict, list, str)) else {})
513
+ args_str = _serialize_tool_args(eff_params)
514
+ if call_id not in ws_index:
515
+ ws_index[call_id] = ws_next_index
516
+ ws_next_index += 1
517
+ _idx = ws_index.get(call_id, 0)
518
+ delta_chunk = {
519
+ "id": response_id,
520
+ "object": "chat.completion.chunk",
521
+ "created": created,
522
+ "model": model,
523
+ "choices": [
524
+ {
525
+ "index": 0,
526
+ "delta": {
527
+ "tool_calls": [
528
+ {
529
+ "index": _idx,
530
+ "id": call_id,
531
+ "type": "function",
532
+ "function": {"name": "web_search", "arguments": args_str},
533
+ }
534
+ ]
535
+ },
536
+ "finish_reason": None,
537
+ }
538
+ ],
539
+ }
540
+ yield f"data: {json.dumps(delta_chunk)}\n\n".encode("utf-8")
541
+ if kind.endswith(".completed") or kind.endswith(".done"):
542
+ finish_chunk = {
543
+ "id": response_id,
544
+ "object": "chat.completion.chunk",
545
+ "created": created,
546
+ "model": model,
547
+ "choices": [
548
+ {"index": 0, "delta": {}, "finish_reason": "tool_calls"}
549
+ ],
550
+ }
551
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode("utf-8")
552
+ except Exception:
553
+ pass
554
+
555
+ if kind == "response.output_text.delta":
556
+ delta = evt.get("delta") or ""
557
+ if compat == "think-tags" and think_open and not think_closed:
558
+ close_chunk = {
559
+ "id": response_id,
560
+ "object": "chat.completion.chunk",
561
+ "created": created,
562
+ "model": model,
563
+ "choices": [{"index": 0, "delta": {"content": "</think>"}, "finish_reason": None}],
564
+ }
565
+ yield f"data: {json.dumps(close_chunk)}\n\n".encode("utf-8")
566
+ think_open = False
567
+ think_closed = True
568
+ saw_output = True
569
+ chunk = {
570
+ "id": response_id,
571
+ "object": "chat.completion.chunk",
572
+ "created": created,
573
+ "model": model,
574
+ "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
575
+ }
576
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
577
+ elif kind == "response.output_item.done":
578
+ item = evt.get("item") or {}
579
+ if isinstance(item, dict) and (item.get("type") == "function_call" or item.get("type") == "web_search_call"):
580
+ call_id = item.get("call_id") or item.get("id") or ""
581
+ name = item.get("name") or ("web_search" if item.get("type") == "web_search_call" else "")
582
+ raw_args = item.get("arguments") or item.get("parameters")
583
+ if isinstance(raw_args, dict):
584
+ try:
585
+ ws_state.setdefault(call_id, {}).update(raw_args)
586
+ except Exception:
587
+ pass
588
+ eff_args = ws_state.get(call_id, raw_args if isinstance(raw_args, (dict, list, str)) else {})
589
+ try:
590
+ args = _serialize_tool_args(eff_args)
591
+ except Exception:
592
+ args = "{}"
593
+ if item.get("type") == "web_search_call" and verbose and vlog:
594
+ try:
595
+ vlog(f"CM_TOOLS response.output_item.done web_search_call id={call_id} has_args={bool(args)}")
596
+ except Exception:
597
+ pass
598
+ if call_id not in ws_index:
599
+ ws_index[call_id] = ws_next_index
600
+ ws_next_index += 1
601
+ _idx = ws_index.get(call_id, 0)
602
+ if isinstance(call_id, str) and isinstance(name, str) and isinstance(args, str):
603
+ delta_chunk = {
604
+ "id": response_id,
605
+ "object": "chat.completion.chunk",
606
+ "created": created,
607
+ "model": model,
608
+ "choices": [
609
+ {
610
+ "index": 0,
611
+ "delta": {
612
+ "tool_calls": [
613
+ {
614
+ "index": _idx,
615
+ "id": call_id,
616
+ "type": "function",
617
+ "function": {"name": name, "arguments": args},
618
+ }
619
+ ]
620
+ },
621
+ "finish_reason": None,
622
+ }
623
+ ],
624
+ }
625
+ yield f"data: {json.dumps(delta_chunk)}\n\n".encode("utf-8")
626
+
627
+ finish_chunk = {
628
+ "id": response_id,
629
+ "object": "chat.completion.chunk",
630
+ "created": created,
631
+ "model": model,
632
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
633
+ }
634
+ yield f"data: {json.dumps(finish_chunk)}\n\n".encode("utf-8")
635
+ elif kind == "response.reasoning_summary_part.added":
636
+ if compat in ("think-tags", "o3"):
637
+ if saw_any_summary:
638
+ pending_summary_paragraph = True
639
+ else:
640
+ saw_any_summary = True
641
+ elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
642
+ delta_txt = evt.get("delta") or ""
643
+ if compat == "o3":
644
+ if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
645
+ nl_chunk = {
646
+ "id": response_id,
647
+ "object": "chat.completion.chunk",
648
+ "created": created,
649
+ "model": model,
650
+ "choices": [
651
+ {
652
+ "index": 0,
653
+ "delta": {"reasoning": {"content": [{"type": "text", "text": "\n"}]}},
654
+ "finish_reason": None,
655
+ }
656
+ ],
657
+ }
658
+ yield f"data: {json.dumps(nl_chunk)}\n\n".encode("utf-8")
659
+ pending_summary_paragraph = False
660
+ chunk = {
661
+ "id": response_id,
662
+ "object": "chat.completion.chunk",
663
+ "created": created,
664
+ "model": model,
665
+ "choices": [
666
+ {
667
+ "index": 0,
668
+ "delta": {"reasoning": {"content": [{"type": "text", "text": delta_txt}]}},
669
+ "finish_reason": None,
670
+ }
671
+ ],
672
+ }
673
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
674
+ elif compat == "think-tags":
675
+ if not think_open and not think_closed:
676
+ open_chunk = {
677
+ "id": response_id,
678
+ "object": "chat.completion.chunk",
679
+ "created": created,
680
+ "model": model,
681
+ "choices": [{"index": 0, "delta": {"content": "<think>"}, "finish_reason": None}],
682
+ }
683
+ yield f"data: {json.dumps(open_chunk)}\n\n".encode("utf-8")
684
+ think_open = True
685
+ if think_open and not think_closed:
686
+ if kind == "response.reasoning_summary_text.delta" and pending_summary_paragraph:
687
+ nl_chunk = {
688
+ "id": response_id,
689
+ "object": "chat.completion.chunk",
690
+ "created": created,
691
+ "model": model,
692
+ "choices": [{"index": 0, "delta": {"content": "\n"}, "finish_reason": None}],
693
+ }
694
+ yield f"data: {json.dumps(nl_chunk)}\n\n".encode("utf-8")
695
+ pending_summary_paragraph = False
696
+ content_chunk = {
697
+ "id": response_id,
698
+ "object": "chat.completion.chunk",
699
+ "created": created,
700
+ "model": model,
701
+ "choices": [{"index": 0, "delta": {"content": delta_txt}, "finish_reason": None}],
702
+ }
703
+ yield f"data: {json.dumps(content_chunk)}\n\n".encode("utf-8")
704
+ else:
705
+ if kind == "response.reasoning_summary_text.delta":
706
+ chunk = {
707
+ "id": response_id,
708
+ "object": "chat.completion.chunk",
709
+ "created": created,
710
+ "model": model,
711
+ "choices": [
712
+ {
713
+ "index": 0,
714
+ "delta": {"reasoning_summary": delta_txt, "reasoning": delta_txt},
715
+ "finish_reason": None,
716
+ }
717
+ ],
718
+ }
719
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
720
+ else:
721
+ chunk = {
722
+ "id": response_id,
723
+ "object": "chat.completion.chunk",
724
+ "created": created,
725
+ "model": model,
726
+ "choices": [
727
+ {"index": 0, "delta": {"reasoning": delta_txt}, "finish_reason": None}
728
+ ],
729
+ }
730
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
731
+ elif isinstance(kind, str) and kind.endswith(".done"):
732
+ pass
733
+ elif kind == "response.output_text.done":
734
+ chunk = {
735
+ "id": response_id,
736
+ "object": "chat.completion.chunk",
737
+ "created": created,
738
+ "model": model,
739
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
740
+ }
741
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
742
+ sent_stop_chunk = True
743
+ elif kind == "response.failed":
744
+ err = evt.get("response", {}).get("error", {}).get("message", "response.failed")
745
+ chunk = {"error": {"message": err}}
746
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
747
+ elif kind == "response.completed":
748
+ m = _extract_usage(evt)
749
+ if m:
750
+ upstream_usage = m
751
+ if compat == "think-tags" and think_open and not think_closed:
752
+ close_chunk = {
753
+ "id": response_id,
754
+ "object": "chat.completion.chunk",
755
+ "created": created,
756
+ "model": model,
757
+ "choices": [{"index": 0, "delta": {"content": "</think>"}, "finish_reason": None}],
758
+ }
759
+ yield f"data: {json.dumps(close_chunk)}\n\n".encode("utf-8")
760
+ think_open = False
761
+ think_closed = True
762
+ if not sent_stop_chunk:
763
+ chunk = {
764
+ "id": response_id,
765
+ "object": "chat.completion.chunk",
766
+ "created": created,
767
+ "model": model,
768
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
769
+ }
770
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
771
+ sent_stop_chunk = True
772
+
773
+ if include_usage and upstream_usage:
774
+ try:
775
+ usage_chunk = {
776
+ "id": response_id,
777
+ "object": "chat.completion.chunk",
778
+ "created": created,
779
+ "model": model,
780
+ "choices": [{"index": 0, "delta": {}, "finish_reason": None}],
781
+ "usage": upstream_usage,
782
+ }
783
+ yield f"data: {json.dumps(usage_chunk)}\n\n".encode("utf-8")
784
+ except Exception:
785
+ pass
786
+ yield b"data: [DONE]\n\n"
787
+ break
788
+ finally:
789
+ upstream.close()
790
+
791
+
792
+ def sse_translate_text(upstream, model: str, created: int, verbose: bool = False, vlog=None, *, include_usage: bool = False):
793
+ response_id = "cmpl-stream"
794
+ upstream_usage = None
795
+
796
+ def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
797
+ try:
798
+ usage = (evt.get("response") or {}).get("usage")
799
+ if not isinstance(usage, dict):
800
+ return None
801
+ pt = int(usage.get("input_tokens") or 0)
802
+ ct = int(usage.get("output_tokens") or 0)
803
+ tt = int(usage.get("total_tokens") or (pt + ct))
804
+ return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
805
+ except Exception:
806
+ return None
807
+ try:
808
+ for raw_line in upstream.iter_lines(decode_unicode=False):
809
+ if not raw_line:
810
+ continue
811
+ line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, (bytes, bytearray)) else raw_line
812
+ if verbose and vlog:
813
+ vlog(line)
814
+ if not line.startswith("data: "):
815
+ continue
816
+ data = line[len("data: "):].strip()
817
+ if not data or data == "[DONE]":
818
+ if data == "[DONE]":
819
+ chunk = {
820
+ "id": response_id,
821
+ "object": "text_completion.chunk",
822
+ "created": created,
823
+ "model": model,
824
+ "choices": [{"index": 0, "text": "", "finish_reason": "stop"}],
825
+ }
826
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
827
+ continue
828
+ try:
829
+ evt = json.loads(data)
830
+ except Exception:
831
+ continue
832
+ kind = evt.get("type")
833
+ if isinstance(evt.get("response"), dict) and isinstance(evt["response"].get("id"), str):
834
+ response_id = evt["response"].get("id") or response_id
835
+ if kind == "response.output_text.delta":
836
+ delta_text = evt.get("delta") or ""
837
+ chunk = {
838
+ "id": response_id,
839
+ "object": "text_completion.chunk",
840
+ "created": created,
841
+ "model": model,
842
+ "choices": [{"index": 0, "text": delta_text, "finish_reason": None}],
843
+ }
844
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
845
+ elif kind == "response.output_text.done":
846
+ chunk = {
847
+ "id": response_id,
848
+ "object": "text_completion.chunk",
849
+ "created": created,
850
+ "model": model,
851
+ "choices": [{"index": 0, "text": "", "finish_reason": "stop"}],
852
+ }
853
+ yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
854
+ elif kind == "response.completed":
855
+ m = _extract_usage(evt)
856
+ if m:
857
+ upstream_usage = m
858
+ if include_usage and upstream_usage:
859
+ try:
860
+ usage_chunk = {
861
+ "id": response_id,
862
+ "object": "text_completion.chunk",
863
+ "created": created,
864
+ "model": model,
865
+ "choices": [{"index": 0, "text": "", "finish_reason": None}],
866
+ "usage": upstream_usage,
867
+ }
868
+ yield f"data: {json.dumps(usage_chunk)}\n\n".encode("utf-8")
869
+ except Exception:
870
+ pass
871
+ yield b"data: [DONE]\n\n"
872
+ break
873
+ finally:
874
+ upstream.close()
build/lib/chatmock/version.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ __version__ = "1.37"
build/lib/chatmock/websocket_routes.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import ssl
6
+ from typing import Any, Dict
7
+
8
+ import certifi
9
+ from flask import current_app, request
10
+ from flask_sock import Sock
11
+ from websockets.sync.client import connect as websocket_connect
12
+ from websockets.exceptions import ConnectionClosed
13
+
14
+ from .responses_api import (
15
+ ResponsesRequestError,
16
+ extract_client_session_id,
17
+ normalize_responses_payload,
18
+ )
19
+ from .session import (
20
+ clear_responses_reuse_state,
21
+ note_responses_stream_event,
22
+ prepare_responses_request_for_session,
23
+ )
24
+ from .upstream import build_upstream_headers, build_upstream_websocket_url
25
+ from .utils import get_effective_chatgpt_auth
26
+
27
+
28
+ def _log_json(prefix: str, payload: Any) -> None:
29
+ try:
30
+ print(f"{prefix}\n{json.dumps(payload, indent=2, ensure_ascii=False)}")
31
+ except Exception:
32
+ try:
33
+ print(f"{prefix}\n{payload}")
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _error_event(message: str, *, status_code: int = 400, code: str | None = None) -> Dict[str, Any]:
39
+ error: Dict[str, Any] = {"message": message}
40
+ if code:
41
+ error["code"] = code
42
+ return {"type": "error", "status_code": status_code, "error": error}
43
+
44
+
45
+ def _is_terminal_event(event: Any) -> bool:
46
+ if not isinstance(event, dict):
47
+ return False
48
+ kind = event.get("type")
49
+ return kind in ("response.completed", "response.failed", "error")
50
+
51
+
52
+ def _build_websocket_ssl_context() -> ssl.SSLContext:
53
+ cafile = (
54
+ os.getenv("CODEX_CA_CERTIFICATE")
55
+ or os.getenv("SSL_CERT_FILE")
56
+ or certifi.where()
57
+ )
58
+ return ssl.create_default_context(cafile=cafile)
59
+
60
+
61
+ def connect_upstream_websocket(url: str, headers: Dict[str, str]):
62
+ return websocket_connect(
63
+ url,
64
+ additional_headers=headers,
65
+ open_timeout=15,
66
+ ssl=_build_websocket_ssl_context(),
67
+ )
68
+
69
+
70
+ def register_websocket_routes(sock: Sock) -> None:
71
+ @sock.route("/v1/responses")
72
+ def responses_websocket(ws) -> None:
73
+ verbose = bool(current_app.config.get("VERBOSE"))
74
+ upstream_ws = None
75
+ upstream_session_id: str | None = None
76
+ active_session_id: str | None = None
77
+
78
+ def _send_error(message: str, *, status_code: int = 400, code: str | None = None) -> None:
79
+ evt = _error_event(message, status_code=status_code, code=code)
80
+ if verbose:
81
+ _log_json("STREAM OUT WS /v1/responses (error)", evt)
82
+ try:
83
+ ws.send(json.dumps(evt))
84
+ except Exception:
85
+ pass
86
+
87
+ try:
88
+ while True:
89
+ incoming = ws.receive()
90
+ if incoming is None:
91
+ break
92
+
93
+ if isinstance(incoming, bytes):
94
+ incoming_text = incoming.decode("utf-8", errors="ignore")
95
+ else:
96
+ incoming_text = str(incoming)
97
+ if verbose:
98
+ print("IN WS /v1/responses\n" + incoming_text)
99
+
100
+ try:
101
+ payload = json.loads(incoming_text)
102
+ except Exception:
103
+ _send_error("Websocket frames must be valid JSON objects.", status_code=400)
104
+ break
105
+
106
+ if not isinstance(payload, dict):
107
+ _send_error("Websocket frames must be JSON objects.", status_code=400)
108
+ break
109
+
110
+ client_session_id = extract_client_session_id(request.headers)
111
+ outbound_text = incoming_text
112
+ session_id = upstream_session_id
113
+
114
+ if payload.get("type") == "response.create":
115
+ try:
116
+ normalized = normalize_responses_payload(
117
+ payload,
118
+ config=current_app.config,
119
+ client_session_id=client_session_id,
120
+ )
121
+ except ResponsesRequestError as exc:
122
+ _send_error(str(exc), status_code=exc.status_code, code=exc.code)
123
+ continue
124
+
125
+ if normalized.service_tier_resolution.warning_message and verbose:
126
+ print(f"[FastMode] {normalized.service_tier_resolution.warning_message}")
127
+ prepared = prepare_responses_request_for_session(
128
+ normalized.session_id,
129
+ normalized.payload,
130
+ allow_previous_response_id=True,
131
+ )
132
+ outbound_text = json.dumps(prepared.payload)
133
+ session_id = normalized.session_id
134
+ active_session_id = normalized.session_id
135
+ if verbose:
136
+ _log_json("OUTBOUND >> ChatGPT Responses WS payload", prepared.payload)
137
+ elif upstream_ws is None:
138
+ _send_error(
139
+ "The first websocket message must be a response.create request.",
140
+ status_code=400,
141
+ )
142
+ break
143
+
144
+ if upstream_ws is None or (session_id and session_id != upstream_session_id):
145
+ access_token, account_id = get_effective_chatgpt_auth()
146
+ if not access_token or not account_id:
147
+ if session_id:
148
+ clear_responses_reuse_state(session_id)
149
+ _send_error(
150
+ "Missing ChatGPT credentials. Run 'python3 chatmock.py login' first.",
151
+ status_code=401,
152
+ )
153
+ break
154
+
155
+ if upstream_ws is not None:
156
+ try:
157
+ upstream_ws.close()
158
+ except Exception:
159
+ pass
160
+
161
+ effective_session_id = session_id or client_session_id or ""
162
+ try:
163
+ upstream_ws = connect_upstream_websocket(
164
+ build_upstream_websocket_url(),
165
+ build_upstream_headers(
166
+ access_token,
167
+ account_id,
168
+ effective_session_id,
169
+ accept="application/json",
170
+ ),
171
+ )
172
+ except Exception as exc:
173
+ if session_id:
174
+ clear_responses_reuse_state(session_id)
175
+ _send_error(
176
+ f"Upstream websocket connection failed: {exc}",
177
+ status_code=502,
178
+ )
179
+ break
180
+ upstream_session_id = effective_session_id
181
+
182
+ upstream_ws.send(outbound_text)
183
+
184
+ while True:
185
+ try:
186
+ upstream_message = upstream_ws.recv()
187
+ except ConnectionClosed:
188
+ if active_session_id:
189
+ clear_responses_reuse_state(active_session_id)
190
+ _send_error("Upstream websocket closed unexpectedly.", status_code=502)
191
+ return
192
+ if upstream_message is None:
193
+ if active_session_id:
194
+ clear_responses_reuse_state(active_session_id)
195
+ _send_error("Upstream websocket closed unexpectedly.", status_code=502)
196
+ return
197
+ if verbose:
198
+ try:
199
+ print("STREAM OUT WS /v1/responses\n" + str(upstream_message))
200
+ except Exception:
201
+ pass
202
+ ws.send(upstream_message)
203
+
204
+ try:
205
+ parsed = json.loads(upstream_message)
206
+ except Exception:
207
+ parsed = None
208
+ if isinstance(parsed, dict) and active_session_id:
209
+ note_responses_stream_event(active_session_id, parsed)
210
+ if _is_terminal_event(parsed):
211
+ if isinstance(parsed, dict) and parsed.get("type") in ("response.failed", "error"):
212
+ if upstream_ws is not None:
213
+ try:
214
+ upstream_ws.close()
215
+ except Exception:
216
+ pass
217
+ upstream_ws = None
218
+ upstream_session_id = None
219
+ break
220
+ finally:
221
+ if upstream_ws is not None:
222
+ try:
223
+ upstream_ws.close()
224
+ except Exception:
225
+ pass
chatmock.egg-info/PKG-INFO ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: chatmock
3
+ Version: 1.37
4
+ Requires-Python: >=3.11
5
+ Description-Content-Type: text/markdown
6
+ License-File: LICENSE
7
+ Requires-Dist: blinker==1.9.0
8
+ Requires-Dist: certifi==2025.8.3
9
+ Requires-Dist: flask==3.1.1
10
+ Requires-Dist: flask-sock==0.7.0
11
+ Requires-Dist: idna==3.10
12
+ Requires-Dist: itsdangerous==2.2.0
13
+ Requires-Dist: jinja2==3.1.6
14
+ Requires-Dist: markupsafe==3.0.2
15
+ Requires-Dist: requests==2.32.5
16
+ Requires-Dist: urllib3==2.5.0
17
+ Requires-Dist: websockets==15.0.1
18
+ Requires-Dist: werkzeug==3.1.3
19
+ Provides-Extra: gui
20
+ Requires-Dist: Pillow==11.3.0; extra == "gui"
21
+ Requires-Dist: PyInstaller==6.16.0; extra == "gui"
22
+ Requires-Dist: PySide6==6.9.2; extra == "gui"
23
+ Dynamic: license-file
24
+
25
+ <div align="center">
26
+
27
+ # ChatMock
28
+
29
+ **Allows Codex to work in your favourite chat apps and coding tools.**
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/chatmock?color=blue&label=pypi)](https://pypi.org/project/chatmock/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/chatmock)](https://pypi.org/project/chatmock/)
33
+ [![License](https://img.shields.io/github/license/RayBytes/ChatMock)](LICENSE)
34
+ [![Stars](https://img.shields.io/github/stars/RayBytes/ChatMock?style=flat)](https://github.com/RayBytes/ChatMock/stargazers)
35
+ [![Last Commit](https://img.shields.io/github/last-commit/RayBytes/ChatMock)](https://github.com/RayBytes/ChatMock/commits/main)
36
+ [![Issues](https://img.shields.io/github/issues/RayBytes/ChatMock)](https://github.com/RayBytes/ChatMock/issues)
37
+
38
+ <br>
39
+
40
+
41
+ </div>
42
+
43
+ <br>
44
+
45
+ ## Install
46
+
47
+ #### Homebrew
48
+ ```bash
49
+ brew tap RayBytes/chatmock
50
+ brew install chatmock
51
+ ```
52
+
53
+ #### pipx / pip
54
+ ```bash
55
+ pipx install chatmock
56
+ ```
57
+
58
+ #### GUI
59
+ Download from [releases](https://github.com/RayBytes/ChatMock/releases) (macOS & Windows)
60
+
61
+ #### Docker
62
+ See [DOCKER.md](DOCKER.md)
63
+
64
+ <br>
65
+
66
+ ## Getting Started
67
+
68
+ ```bash
69
+ # 1. Sign in with your ChatGPT account
70
+ chatmock login
71
+
72
+ # 2. Start the server
73
+ chatmock serve
74
+ ```
75
+
76
+ The server runs at `http://127.0.0.1:8000` by default. Use `http://127.0.0.1:8000/v1` as your base URL for OpenAI-compatible apps.
77
+
78
+ <br>
79
+
80
+ ## Usage
81
+
82
+ <details open>
83
+ <summary><b>Python</b></summary>
84
+
85
+ ```python
86
+ from openai import OpenAI
87
+
88
+ client = OpenAI(
89
+ base_url="http://127.0.0.1:8000/v1",
90
+ api_key="anything" # not checked
91
+ )
92
+
93
+ response = client.chat.completions.create(
94
+ model="gpt-5.4",
95
+ messages=[{"role": "user", "content": "hello"}]
96
+ )
97
+ print(response.choices[0].message.content)
98
+ ```
99
+
100
+ </details>
101
+
102
+ <details>
103
+ <summary><b>cURL</b></summary>
104
+
105
+ ```bash
106
+ curl http://127.0.0.1:8000/v1/chat/completions \
107
+ -H "Content-Type: application/json" \
108
+ -d '{
109
+ "model": "gpt-5.4",
110
+ "messages": [{"role": "user", "content": "hello"}]
111
+ }'
112
+ ```
113
+
114
+ </details>
115
+
116
+ <br>
117
+
118
+ ## Supported Models
119
+
120
+ - `gpt-5.4`
121
+ - `gpt-5.4-mini`
122
+ - `gpt-5.2`
123
+ - `gpt-5.1`
124
+ - `gpt-5`
125
+ - `gpt-5.3-codex`
126
+ - `gpt-5.3-codex-spark`
127
+ - `gpt-5.2-codex`
128
+ - `gpt-5-codex`
129
+ - `gpt-5.1-codex`
130
+ - `gpt-5.1-codex-max`
131
+ - `gpt-5.1-codex-mini`
132
+ - `codex-mini`
133
+
134
+ <br>
135
+
136
+ ## Features
137
+
138
+ - Tool / function calling
139
+ - Vision / image input
140
+ - Thinking summaries (via think tags)
141
+ - Configurable thinking effort
142
+ - Fast mode for supported models
143
+ - Web search tool
144
+ - OpenAI-compatible `/v1/responses` (HTTP + WebSocket)
145
+ - Ollama-compatible endpoints
146
+ - Reasoning effort exposed as separate models (optional)
147
+
148
+ <br>
149
+
150
+ ## Configuration
151
+
152
+ All flags go after `chatmock serve`. These can also be set as environment variables.
153
+
154
+ | Flag | Env var | Options | Default | Description |
155
+ |------|---------|---------|---------|-------------|
156
+ | `--reasoning-effort` | `CHATGPT_LOCAL_REASONING_EFFORT` | none, minimal, low, medium, high, xhigh | medium | How hard the model thinks |
157
+ | `--reasoning-summary` | `CHATGPT_LOCAL_REASONING_SUMMARY` | auto, concise, detailed, none | auto | Thinking summary verbosity |
158
+ | `--reasoning-compat` | `CHATGPT_LOCAL_REASONING_COMPAT` | legacy, o3, think-tags | think-tags | How reasoning is returned to the client |
159
+ | `--fast-mode` | `CHATGPT_LOCAL_FAST_MODE` | true/false | false | Priority processing for supported models |
160
+ | `--enable-web-search` | `CHATGPT_LOCAL_ENABLE_WEB_SEARCH` | true/false | false | Allow the model to search the web |
161
+ | `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level as its own model |
162
+
163
+ <details>
164
+ <summary><b>Web search in a request</b></summary>
165
+
166
+ ```json
167
+ {
168
+ "model": "gpt-5.4",
169
+ "messages": [{"role": "user", "content": "latest news on ..."}],
170
+ "responses_tools": [{"type": "web_search"}],
171
+ "responses_tool_choice": "auto"
172
+ }
173
+ ```
174
+
175
+ </details>
176
+
177
+ <details>
178
+ <summary><b>Fast mode in a request</b></summary>
179
+
180
+ ```json
181
+ {
182
+ "model": "gpt-5.4",
183
+ "input": "summarize this",
184
+ "fast_mode": true
185
+ }
186
+ ```
187
+
188
+ </details>
189
+
190
+ <br>
191
+
192
+ ## Notes
193
+
194
+ Use responsibly and at your own risk. This project is not affiliated with OpenAI.
195
+
196
+ <br>
197
+
198
+ ## Star History
199
+
200
+ [![Star History Chart](https://api.star-history.com/svg?repos=RayBytes/ChatMock&type=Timeline)](https://www.star-history.com/#RayBytes/ChatMock&Timeline)
chatmock.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ chatmock/__init__.py
5
+ chatmock/app.py
6
+ chatmock/cli.py
7
+ chatmock/config.py
8
+ chatmock/fast_mode.py
9
+ chatmock/http.py
10
+ chatmock/limits.py
11
+ chatmock/model_registry.py
12
+ chatmock/models.py
13
+ chatmock/oauth.py
14
+ chatmock/prompt.md
15
+ chatmock/prompt_gpt5_codex.md
16
+ chatmock/reasoning.py
17
+ chatmock/responses_api.py
18
+ chatmock/routes_ollama.py
19
+ chatmock/routes_openai.py
20
+ chatmock/session.py
21
+ chatmock/transform.py
22
+ chatmock/upstream.py
23
+ chatmock/utils.py
24
+ chatmock/version.py
25
+ chatmock/websocket_routes.py
26
+ chatmock.egg-info/PKG-INFO
27
+ chatmock.egg-info/SOURCES.txt
28
+ chatmock.egg-info/dependency_links.txt
29
+ chatmock.egg-info/entry_points.txt
30
+ chatmock.egg-info/requires.txt
31
+ chatmock.egg-info/top_level.txt
32
+ tests/test_fast_mode.py
33
+ tests/test_models.py
34
+ tests/test_routes.py
chatmock.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
chatmock.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ chatmock = chatmock.cli:main
chatmock.egg-info/requires.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ blinker==1.9.0
2
+ certifi==2025.8.3
3
+ flask==3.1.1
4
+ flask-sock==0.7.0
5
+ idna==3.10
6
+ itsdangerous==2.2.0
7
+ jinja2==3.1.6
8
+ markupsafe==3.0.2
9
+ requests==2.32.5
10
+ urllib3==2.5.0
11
+ websockets==15.0.1
12
+ werkzeug==3.1.3
13
+
14
+ [gui]
15
+ Pillow==11.3.0
16
+ PyInstaller==6.16.0
17
+ PySide6==6.9.2
chatmock.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ chatmock
chatmock.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from chatmock.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
7
+
chatmock/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .app import create_app
4
+ from .cli import main
5
+ from .version import __version__
chatmock/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (341 Bytes). View file
 
chatmock/__pycache__/app.cpython-314.pyc ADDED
Binary file (2.83 kB). View file
 
chatmock/__pycache__/cli.cpython-314.pyc ADDED
Binary file (21.6 kB). View file
 
chatmock/__pycache__/config.cpython-314.pyc ADDED
Binary file (3.07 kB). View file
 
chatmock/__pycache__/fast_mode.cpython-314.pyc ADDED
Binary file (3.56 kB). View file
 
chatmock/__pycache__/http.cpython-314.pyc ADDED
Binary file (1.82 kB). View file
 
chatmock/__pycache__/limits.cpython-314.pyc ADDED
Binary file (10.9 kB). View file
 
chatmock/__pycache__/model_registry.cpython-314.pyc ADDED
Binary file (7.71 kB). View file
 
chatmock/__pycache__/models.cpython-314.pyc ADDED
Binary file (1.23 kB). View file