Upload 97 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .README.md.swp +0 -0
- .env.example +26 -0
- .github/workflows/ci.yml +20 -0
- .github/workflows/release.yml +229 -0
- .gitignore +21 -0
- CONTRIBUTING.md +37 -0
- DOCKER.md +41 -0
- Dockerfile +20 -0
- LICENSE +21 -0
- README.md +209 -10
- build.py +225 -0
- build/lib/chatmock/__init__.py +5 -0
- build/lib/chatmock/app.py +56 -0
- build/lib/chatmock/cli.py +425 -0
- build/lib/chatmock/config.py +48 -0
- build/lib/chatmock/fast_mode.py +92 -0
- build/lib/chatmock/http.py +24 -0
- build/lib/chatmock/limits.py +200 -0
- build/lib/chatmock/model_registry.py +198 -0
- build/lib/chatmock/models.py +26 -0
- build/lib/chatmock/oauth.py +340 -0
- build/lib/chatmock/prompt.md +1 -0
- build/lib/chatmock/prompt_gpt5_codex.md +1 -0
- build/lib/chatmock/reasoning.py +79 -0
- build/lib/chatmock/responses_api.py +243 -0
- build/lib/chatmock/routes_ollama.py +585 -0
- build/lib/chatmock/routes_openai.py +738 -0
- build/lib/chatmock/session.py +312 -0
- build/lib/chatmock/transform.py +149 -0
- build/lib/chatmock/upstream.py +181 -0
- build/lib/chatmock/utils.py +874 -0
- build/lib/chatmock/version.py +4 -0
- build/lib/chatmock/websocket_routes.py +225 -0
- chatmock.egg-info/PKG-INFO +200 -0
- chatmock.egg-info/SOURCES.txt +34 -0
- chatmock.egg-info/dependency_links.txt +1 -0
- chatmock.egg-info/entry_points.txt +2 -0
- chatmock.egg-info/requires.txt +17 -0
- chatmock.egg-info/top_level.txt +1 -0
- chatmock.py +7 -0
- chatmock/__init__.py +5 -0
- chatmock/__pycache__/__init__.cpython-314.pyc +0 -0
- chatmock/__pycache__/app.cpython-314.pyc +0 -0
- chatmock/__pycache__/cli.cpython-314.pyc +0 -0
- chatmock/__pycache__/config.cpython-314.pyc +0 -0
- chatmock/__pycache__/fast_mode.cpython-314.pyc +0 -0
- chatmock/__pycache__/http.cpython-314.pyc +0 -0
- chatmock/__pycache__/limits.cpython-314.pyc +0 -0
- chatmock/__pycache__/model_registry.cpython-314.pyc +0 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# ChatMock
|
| 4 |
+
|
| 5 |
+
**Allows Codex to work in your favourite chat apps and coding tools.**
|
| 6 |
+
|
| 7 |
+
[](https://pypi.org/project/chatmock/)
|
| 8 |
+
[](https://pypi.org/project/chatmock/)
|
| 9 |
+
[](LICENSE)
|
| 10 |
+
[](https://github.com/RayBytes/ChatMock/stargazers)
|
| 11 |
+
[](https://github.com/RayBytes/ChatMock/commits/main)
|
| 12 |
+
[](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 |
+
[](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 |
+
[](https://pypi.org/project/chatmock/)
|
| 32 |
+
[](https://pypi.org/project/chatmock/)
|
| 33 |
+
[](LICENSE)
|
| 34 |
+
[](https://github.com/RayBytes/ChatMock/stargazers)
|
| 35 |
+
[](https://github.com/RayBytes/ChatMock/commits/main)
|
| 36 |
+
[](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 |
+
[](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
|
|
|