diff --git a/.github/scripts/release-docker-smoke.sh b/.github/scripts/release-docker-smoke.sh index ab66a642..3c3475fd 100755 --- a/.github/scripts/release-docker-smoke.sh +++ b/.github/scripts/release-docker-smoke.sh @@ -4,6 +4,7 @@ umask 077 release_version="${RELEASE_VERSION:-v2.0.0-local}" suffix="${RELEASE_SMOKE_SUFFIX:-local-$$}" +source_image="${RELEASE_SMOKE_SOURCE_IMAGE:-}" case "${suffix}" in *[!A-Za-z0-9_-]*) printf 'RELEASE_SMOKE_SUFFIX contains unsupported characters\n' >&2 @@ -117,10 +118,16 @@ case "${docker_arch}" in ;; esac -docker build \ - --platform "${platform}" \ - --build-arg "VERSION=${release_version}" \ - -t "${image}" . +if [[ -n "${source_image}" ]]; then + test "${source_image}" != "${image}" + docker pull --platform "${platform}" "${source_image}" + docker image tag "${source_image}" "${image}" +else + docker build \ + --platform "${platform}" \ + --build-arg "VERSION=${release_version}" \ + -t "${image}" . +fi test "$(docker image inspect -f '{{.Config.User}}' "${image}")" = "10001:10001" docker volume create "${volume}" >/dev/null @@ -326,7 +333,9 @@ node -e ' const prices=JSON.parse(fs.readFileSync(process.argv[2],"utf8")); const usage=JSON.parse(fs.readFileSync(process.argv[3],"utf8")); if(!groups.data.some(item=>item.name==="Task13 Release Smoke Group")) process.exit(1); - if(!prices.data.some(item=>item.pattern==="task13-release-model"&&item.source==="user")) process.exit(1); + if(!prices.data.overrides.some( + item=>item.pattern==="task13-release-model"&&item.source==="user" + )) process.exit(1); if(usage.data.summary.request_count<1||usage.data.summary.total_tokens!==12) process.exit(1); ' \ "${task_tmp}/groups-second.json" \ diff --git a/.github/scripts/release-image-digest.sh b/.github/scripts/release-image-digest.sh new file mode 100755 index 00000000..18b84bd9 --- /dev/null +++ b/.github/scripts/release-image-digest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:?image is required}" +output= +if output="$( + docker buildx imagetools inspect \ + "${image}" \ + --format '{{.Manifest.Digest}}' 2>&1 +)"; then + test -n "${output}" + printf '%s' "${output}" + exit 0 +fi + +if grep -Eqi 'manifest unknown|manifest_unknown' <<<"${output}" || + { + grep -Fqi -- "${image}" <<<"${output}" && + grep -Eqi 'not found|(^|[^0-9])404([^0-9]|$)' <<<"${output}" + }; then + printf 'absent' + exit 0 +fi + +printf '%s\n' "${output}" >&2 +exit 1 diff --git a/.github/scripts/release-native-smoke.ps1 b/.github/scripts/release-native-smoke.ps1 new file mode 100644 index 00000000..3e427f14 --- /dev/null +++ b/.github/scripts/release-native-smoke.ps1 @@ -0,0 +1,226 @@ +$ErrorActionPreference = "Stop" + +$binary = $env:RELEASE_SMOKE_BINARY +$checksumFile = $env:RELEASE_SMOKE_CHECKSUM_FILE +$releaseVersion = $env:RELEASE_VERSION +$port = if ($env:RELEASE_SMOKE_PORT) { $env:RELEASE_SMOKE_PORT } else { "39113" } +foreach ($required in @($binary, $checksumFile, $releaseVersion)) { + if ([string]::IsNullOrWhiteSpace($required)) { + throw "RELEASE_SMOKE_BINARY, RELEASE_SMOKE_CHECKSUM_FILE, and RELEASE_VERSION are required" + } +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +public static class ReleaseNativeProcess +{ + private const uint CREATE_NEW_PROCESS_GROUP = 0x00000200; + private const uint CTRL_BREAK_EVENT = 1; + private const int ERROR_ACCESS_DENIED = 5; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [DllImport( + "kernel32.dll", + CharSet = CharSet.Unicode, + ExactSpelling = true, + SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string currentDirectory, + ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GenerateConsoleCtrlEvent( + uint ctrlEvent, + uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetConsoleCP(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + private static void EnsureConsole() + { + if (GetConsoleCP() != 0 || AllocConsole()) + { + return; + } + var error = Marshal.GetLastWin32Error(); + if (error != ERROR_ACCESS_DENIED || GetConsoleCP() == 0) + { + throw new Win32Exception(error); + } + } + + public static Process Start(string path) + { + EnsureConsole(); + var startupInfo = new STARTUPINFO { + cb = (uint)Marshal.SizeOf() + }; + var commandLine = new StringBuilder("\"" + path + "\""); + PROCESS_INFORMATION processInformation; + if (!CreateProcessW( + path, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + CREATE_NEW_PROCESS_GROUP, + IntPtr.Zero, + null, + ref startupInfo, + out processInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + try + { + return Process.GetProcessById((int)processInformation.dwProcessId); + } + finally + { + CloseHandle(processInformation.hThread); + CloseHandle(processInformation.hProcess); + } + } + + public static void SendCtrlBreak(int processGroupId) + { + if (!GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, (uint)processGroupId)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } +} +'@ + +$filename = [System.IO.Path]::GetFileName($binary) +$checksumLine = Get-Content $checksumFile | + Where-Object { $_ -match "\s$([regex]::Escape($filename))$" } +if (-not $checksumLine) { throw "missing checksum" } +$expectedHash = ($checksumLine -split "\s+")[0].ToLowerInvariant() +$beforeHash = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() +if ($beforeHash -ne $expectedHash) { throw "checksum mismatch before execution" } + +& $binary help | Out-Null +if ($LASTEXITCODE -ne 0) { throw "help failed" } + +$dataDir = Join-Path $env:RUNNER_TEMP "gpt-load-native-smoke-$([guid]::NewGuid())" +New-Item -ItemType Directory -Path $dataDir | Out-Null +$env:DATA_DIR = $dataDir +$env:PORT = $port +$process = [ReleaseNativeProcess]::Start($binary) +try { + $health = $null + for ($attempt = 0; $attempt -lt 80; $attempt++) { + try { + $health = Invoke-RestMethod "http://127.0.0.1:$port/health" + break + } catch { + Start-Sleep -Milliseconds 250 + } + } + if ($null -eq $health) { throw "health check timed out" } + if ($health.version -ne $releaseVersion) { throw "version mismatch" } + + $authFile = Join-Path $dataDir "auth.key" + $encryptionFile = Join-Path $dataDir "encryption.key" + $databaseFile = Join-Path $dataDir "gpt-load.db" + foreach ($file in @($authFile, $encryptionFile, $databaseFile)) { + if (-not (Test-Path $file)) { throw "missing generated asset: $file" } + } + + $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + foreach ($file in @($authFile, $encryptionFile)) { + $acl = Get-Acl $file + if (-not $acl.AreAccessRulesProtected) { throw "credential DACL inherits rules" } + $allowRules = @($acl.Access | Where-Object { + $_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow + }) + if ($allowRules.Count -eq 0) { throw "credential DACL has no allow rule" } + foreach ($rule in $allowRules) { + $sid = $rule.IdentityReference.Translate( + [System.Security.Principal.SecurityIdentifier] + ) + if ($sid.Value -ne $currentSid.Value) { + throw "credential DACL allows another principal" + } + } + } + + $authKey = (Get-Content $authFile -Raw).Trim() + Invoke-WebRequest "http://127.0.0.1:$port/" -UseBasicParsing | Out-Null + $headers = @{ Authorization = "Bearer $authKey" } + Invoke-RestMethod "http://127.0.0.1:$port/api/usage?range=24h" -Headers $headers | Out-Null + Invoke-RestMethod "http://127.0.0.1:$port/api/model-prices" -Headers $headers | Out-Null + + [ReleaseNativeProcess]::SendCtrlBreak($process.Id) + if (-not $process.WaitForExit(15000)) { + throw "graceful shutdown timed out" + } + if ($process.ExitCode -ne 0) { + throw "graceful shutdown exited with code $($process.ExitCode)" + } + + $afterHash = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() + if ($afterHash -ne $expectedHash) { throw "checksum mismatch after execution" } +} finally { + # Stop-Process is failure cleanup only; success is proven by CTRL_BREAK and exit code 0. + if (-not $process.HasExited) { + Stop-Process -Id $process.Id -ErrorAction SilentlyContinue + $process.WaitForExit(5000) | Out-Null + } + Remove-Item -Recurse -Force $dataDir -ErrorAction SilentlyContinue +} diff --git a/.github/scripts/release-native-smoke.sh b/.github/scripts/release-native-smoke.sh new file mode 100755 index 00000000..7b47e302 --- /dev/null +++ b/.github/scripts/release-native-smoke.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +binary="${RELEASE_SMOKE_BINARY:?RELEASE_SMOKE_BINARY is required}" +checksum_file="${RELEASE_SMOKE_CHECKSUM_FILE:?RELEASE_SMOKE_CHECKSUM_FILE is required}" +release_version="${RELEASE_VERSION:?RELEASE_VERSION is required}" +port="${RELEASE_SMOKE_PORT:-39113}" +filename="$(basename "${binary}")" + +expected_hash="$(awk -v name="${filename}" '$2 == name {print $1}' "${checksum_file}")" +test -n "${expected_hash}" +if command -v sha256sum >/dev/null 2>&1; then + before_hash="$(sha256sum "${binary}" | awk '{print $1}')" +else + before_hash="$(shasum -a 256 "${binary}" | awk '{print $1}')" +fi +test "${before_hash}" = "${expected_hash}" + +chmod +x "${binary}" +"${binary}" help >/dev/null + +data_dir="$(mktemp -d)" +log_file="$(mktemp)" +pid= +cleanup() { + if [[ -n "${pid}" ]]; then + kill -TERM "${pid}" >/dev/null 2>&1 || true + wait "${pid}" >/dev/null 2>&1 || true + fi + rm -rf "${data_dir}" "${log_file}" +} +trap cleanup EXIT + +DATA_DIR="${data_dir}" \ + PORT="${port}" \ + "${binary}" >"${log_file}" 2>&1 & +pid=$! +for _ in $(seq 1 80); do + if curl -fsS "http://127.0.0.1:${port}/health" >"${data_dir}/health.json"; then + break + fi + sleep 0.25 +done + +test -s "${data_dir}/health.json" +test -f "${data_dir}/auth.key" +test -f "${data_dir}/encryption.key" +test -f "${data_dir}/gpt-load.db" +auth_key="$(cat "${data_dir}/auth.key")" +test -n "${auth_key}" +grep -F "\"version\":\"${release_version}\"" "${data_dir}/health.json" +curl -fsS "http://127.0.0.1:${port}/" | grep -F '
' >/dev/null +curl -fsS \ + -H "Authorization: Bearer ${auth_key}" \ + "http://127.0.0.1:${port}/api/usage?range=24h" >/dev/null +curl -fsS \ + -H "Authorization: Bearer ${auth_key}" \ + "http://127.0.0.1:${port}/api/model-prices" >/dev/null + +kill -TERM "${pid}" +wait "${pid}" +pid= + +if command -v sha256sum >/dev/null 2>&1; then + after_hash="$(sha256sum "${binary}" | awk '{print $1}')" +else + after_hash="$(shasum -a 256 "${binary}" | awk '{print $1}')" +fi +test "${after_hash}" = "${expected_hash}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d82881ba..334e299b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,9 +111,7 @@ jobs: run: pnpm --dir web run test:e2e - name: Check repository invariants - run: | - git diff --check - cmp -s AGENTS.md CLAUDE.md + run: git diff --check - name: Upload verified web dist uses: actions/upload-artifact@v4 @@ -233,6 +231,9 @@ jobs: filename: gpt-load-windows-amd64.exe runs-on: ${{ matrix.runner }} steps: + - name: Checkout tag commit + uses: actions/checkout@v7 + - name: Download complete release assets uses: actions/download-artifact@v4 with: @@ -242,277 +243,22 @@ jobs: - name: Smoke POSIX native artifact if: runner.os != 'Windows' shell: bash - run: | - set -euo pipefail - binary="${GITHUB_WORKSPACE}/native-smoke/${{ matrix.filename }}" - checksum_file="${GITHUB_WORKSPACE}/native-smoke/SHA256SUMS" - expected_hash="$(awk -v name="${{ matrix.filename }}" '$2 == name {print $1}' "${checksum_file}")" - test -n "${expected_hash}" - if command -v sha256sum >/dev/null 2>&1; then - before_hash="$(sha256sum "${binary}" | awk '{print $1}')" - else - before_hash="$(shasum -a 256 "${binary}" | awk '{print $1}')" - fi - test "${before_hash}" = "${expected_hash}" - chmod +x "${binary}" - "${binary}" help >/dev/null - - data_dir="$(mktemp -d)" - log_file="$(mktemp)" - cleanup() { - if [[ -n "${pid:-}" ]]; then - kill -TERM "${pid}" >/dev/null 2>&1 || true - wait "${pid}" >/dev/null 2>&1 || true - fi - rm -rf "${data_dir}" "${log_file}" - } - trap cleanup EXIT - - DATA_DIR="${data_dir}" \ - PORT=39113 \ - "${binary}" >"${log_file}" 2>&1 & - pid=$! - for _ in $(seq 1 80); do - if curl -fsS http://127.0.0.1:39113/health >"${data_dir}/health.json"; then - break - fi - sleep 0.25 - done - test -f "${data_dir}/auth.key" - test -f "${data_dir}/encryption.key" - test -f "${data_dir}/gpt-load.db" - auth_key="$(cat "${data_dir}/auth.key")" - test -n "${auth_key}" - grep -F "\"version\":\"${GITHUB_REF_NAME}\"" "${data_dir}/health.json" - curl -fsS http://127.0.0.1:39113/ | grep -F '
' - curl -fsS \ - -H "Authorization: Bearer ${auth_key}" \ - 'http://127.0.0.1:39113/api/usage?range=24h' >/dev/null - curl -fsS \ - -H "Authorization: Bearer ${auth_key}" \ - http://127.0.0.1:39113/api/model-prices >/dev/null - kill -TERM "${pid}" - wait "${pid}" - pid= - if command -v sha256sum >/dev/null 2>&1; then - after_hash="$(sha256sum "${binary}" | awk '{print $1}')" - else - after_hash="$(shasum -a 256 "${binary}" | awk '{print $1}')" - fi - test "${after_hash}" = "${expected_hash}" + env: + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_SMOKE_BINARY: ${{ github.workspace }}/native-smoke/${{ matrix.filename }} + RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/native-smoke/SHA256SUMS + run: .github/scripts/release-native-smoke.sh - name: Smoke Windows native artifact if: runner.os == 'Windows' shell: pwsh + env: + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_SMOKE_BINARY: ${{ github.workspace }}/native-smoke/${{ matrix.filename }} + RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/native-smoke/SHA256SUMS run: | - $ErrorActionPreference = "Stop" - Add-Type -TypeDefinition @' - using System; - using System.ComponentModel; - using System.Diagnostics; - using System.Runtime.InteropServices; - using System.Text; - - public static class ReleaseNativeProcess - { - private const uint CREATE_NEW_PROCESS_GROUP = 0x00000200; - private const uint CTRL_BREAK_EVENT = 1; - private const int ERROR_ACCESS_DENIED = 5; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct STARTUPINFO - { - public uint cb; - public string lpReserved; - public string lpDesktop; - public string lpTitle; - public uint dwX; - public uint dwY; - public uint dwXSize; - public uint dwYSize; - public uint dwXCountChars; - public uint dwYCountChars; - public uint dwFillAttribute; - public uint dwFlags; - public ushort wShowWindow; - public ushort cbReserved2; - public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; - } - - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_INFORMATION - { - public IntPtr hProcess; - public IntPtr hThread; - public uint dwProcessId; - public uint dwThreadId; - } - - [DllImport( - "kernel32.dll", - CharSet = CharSet.Unicode, - ExactSpelling = true, - SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CreateProcessW( - string applicationName, - StringBuilder commandLine, - IntPtr processAttributes, - IntPtr threadAttributes, - [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, - uint creationFlags, - IntPtr environment, - string currentDirectory, - ref STARTUPINFO startupInfo, - out PROCESS_INFORMATION processInformation); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GenerateConsoleCtrlEvent( - uint ctrlEvent, - uint processGroupId); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern uint GetConsoleCP(); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AllocConsole(); - - [DllImport("kernel32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CloseHandle(IntPtr handle); - - private static void EnsureConsole() - { - if (GetConsoleCP() != 0 || AllocConsole()) - { - return; - } - var error = Marshal.GetLastWin32Error(); - if (error != ERROR_ACCESS_DENIED || GetConsoleCP() == 0) - { - throw new Win32Exception(error); - } - } - - public static Process Start(string path) - { - EnsureConsole(); - var startupInfo = new STARTUPINFO { - cb = (uint)Marshal.SizeOf() - }; - var commandLine = new StringBuilder("\"" + path + "\""); - PROCESS_INFORMATION processInformation; - if (!CreateProcessW( - path, - commandLine, - IntPtr.Zero, - IntPtr.Zero, - false, - CREATE_NEW_PROCESS_GROUP, - IntPtr.Zero, - null, - ref startupInfo, - out processInformation)) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - try - { - return Process.GetProcessById((int)processInformation.dwProcessId); - } - finally - { - CloseHandle(processInformation.hThread); - CloseHandle(processInformation.hProcess); - } - } - - public static void SendCtrlBreak(int processGroupId) - { - if (!GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, (uint)processGroupId)) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - } - } - '@ - - $binary = Join-Path $env:GITHUB_WORKSPACE "native-smoke/${{ matrix.filename }}" - $checksumFile = Join-Path $env:GITHUB_WORKSPACE "native-smoke/SHA256SUMS" - $checksumLine = Get-Content $checksumFile | - Where-Object { $_ -match "\s${{ matrix.filename }}$" } - if (-not $checksumLine) { throw "missing checksum" } - $expectedHash = ($checksumLine -split "\s+")[0].ToLowerInvariant() - $beforeHash = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() - if ($beforeHash -ne $expectedHash) { throw "checksum mismatch before execution" } - & $binary help | Out-Null - if ($LASTEXITCODE -ne 0) { throw "help failed" } - - $dataDir = Join-Path $env:RUNNER_TEMP "gpt-load-native-smoke" - New-Item -ItemType Directory -Force -Path $dataDir | Out-Null - $env:DATA_DIR = $dataDir - $env:PORT = "39113" - $process = [ReleaseNativeProcess]::Start($binary) - try { - for ($attempt = 0; $attempt -lt 80; $attempt++) { - try { - $health = Invoke-RestMethod "http://127.0.0.1:39113/health" - break - } catch { - Start-Sleep -Milliseconds 250 - } - } - if ($health.version -ne $env:GITHUB_REF_NAME) { throw "version mismatch" } - $authFile = Join-Path $dataDir "auth.key" - $encryptionFile = Join-Path $dataDir "encryption.key" - $databaseFile = Join-Path $dataDir "gpt-load.db" - foreach ($file in @($authFile, $encryptionFile, $databaseFile)) { - if (-not (Test-Path $file)) { throw "missing generated asset: $file" } - } - $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User - foreach ($file in @($authFile, $encryptionFile)) { - $acl = Get-Acl $file - if (-not $acl.AreAccessRulesProtected) { throw "credential DACL inherits rules" } - $allowRules = @($acl.Access | Where-Object { - $_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow - }) - if ($allowRules.Count -eq 0) { throw "credential DACL has no allow rule" } - foreach ($rule in $allowRules) { - $sid = $rule.IdentityReference.Translate( - [System.Security.Principal.SecurityIdentifier] - ) - if ($sid.Value -ne $currentSid.Value) { - throw "credential DACL allows another principal" - } - } - } - $authKey = (Get-Content $authFile -Raw).Trim() - Invoke-WebRequest "http://127.0.0.1:39113/" -UseBasicParsing | Out-Null - $headers = @{ Authorization = "Bearer $authKey" } - Invoke-RestMethod "http://127.0.0.1:39113/api/usage?range=24h" -Headers $headers | Out-Null - Invoke-RestMethod "http://127.0.0.1:39113/api/model-prices" -Headers $headers | Out-Null - [ReleaseNativeProcess]::SendCtrlBreak($process.Id) - if (-not $process.WaitForExit(15000)) { - throw "graceful shutdown timed out" - } - if ($process.ExitCode -ne 0) { - throw "graceful shutdown exited with code $($process.ExitCode)" - } - $afterHash = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() - if ($afterHash -ne $expectedHash) { throw "checksum mismatch after execution" } - } finally { - # Stop-Process is failure cleanup only; success is proven by CTRL_BREAK and exit code 0. - if (-not $process.HasExited) { - Stop-Process -Id $process.Id -ErrorAction SilentlyContinue - $process.WaitForExit(5000) | Out-Null - } - Remove-Item -Recurse -Force $dataDir -ErrorAction SilentlyContinue - } + & .github/scripts/release-native-smoke.ps1 + exit $LASTEXITCODE docker-smoke: needs: @@ -531,6 +277,53 @@ jobs: RELEASE_SMOKE_SUFFIX: ${{ github.run_id }} run: .github/scripts/release-docker-smoke.sh + capture-channel-baseline: + needs: + - validate-tag + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: read + outputs: + ghcr_latest_digest: ${{ steps.snapshot.outputs.ghcr_latest_digest }} + dockerhub_latest_digest: ${{ steps.snapshot.outputs.dockerhub_latest_digest }} + steps: + - name: Checkout tag commit + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR for baseline inspection + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub for baseline inspection + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Capture latest digests before publication + id: snapshot + shell: bash + run: | + set -euo pipefail + ghcr_latest_digest="$( + .github/scripts/release-image-digest.sh ghcr.io/tbphp/gpt-load:latest + )" + dockerhub_latest_digest="$( + .github/scripts/release-image-digest.sh tbphp/gpt-load:latest + )" + printf 'ghcr_latest_digest=%s\n' \ + "${ghcr_latest_digest}" >>"${GITHUB_OUTPUT}" + printf 'dockerhub_latest_digest=%s\n' \ + "${dockerhub_latest_digest}" >>"${GITHUB_OUTPUT}" + publish-github: needs: - validate-tag @@ -600,6 +393,7 @@ jobs: publish-images: needs: - validate-tag + - capture-channel-baseline - verify-and-build-web - build-binaries - package-checksums @@ -662,13 +456,70 @@ jobs: build-args: | VERSION=${{ github.ref_name }} + post-publish-native-smoke: + needs: + - publish-github + - publish-images + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: true + matrix: + include: + - runner: ubuntu-24.04 + filename: gpt-load-linux-amd64 + - runner: ubuntu-24.04-arm + filename: gpt-load-linux-arm64 + - runner: macos-15-intel + filename: gpt-load-macos-amd64 + - runner: macos-15 + filename: gpt-load-macos-arm64 + - runner: windows-latest + filename: gpt-load-windows-amd64.exe + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout tag commit + uses: actions/checkout@v7 + + - name: Download published GitHub Release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p public-release + gh release download "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir public-release + + - name: Smoke published POSIX native artifact + if: runner.os != 'Windows' + shell: bash + env: + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_SMOKE_BINARY: ${{ github.workspace }}/public-release/${{ matrix.filename }} + RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/public-release/SHA256SUMS + run: .github/scripts/release-native-smoke.sh + + - name: Smoke published Windows native artifact + if: runner.os == 'Windows' + shell: pwsh + env: + RELEASE_VERSION: ${{ github.ref_name }} + RELEASE_SMOKE_BINARY: ${{ github.workspace }}/public-release/${{ matrix.filename }} + RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/public-release/SHA256SUMS + run: .github/scripts/release-native-smoke.ps1 + post-publish-verify: needs: - validate-tag + - capture-channel-baseline - publish-github - publish-images + - post-publish-native-smoke runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 45 permissions: contents: read packages: read @@ -676,22 +527,40 @@ jobs: - name: Checkout tag commit uses: actions/checkout@v7 - - name: Verify GitHub Release asset inventory + - name: Download and verify exact GitHub Release asset inventory env: GH_TOKEN: ${{ github.token }} + shell: bash run: | - gh release view "${GITHUB_REF_NAME}" \ - --json assets \ - --jq '.assets[].name' >/tmp/release-assets - for asset in \ - gpt-load-linux-amd64 \ - gpt-load-linux-arm64 \ - gpt-load-macos-amd64 \ - gpt-load-macos-arm64 \ - gpt-load-windows-amd64.exe \ - SHA256SUMS; do - grep -Fx "${asset}" /tmp/release-assets - done + set -euo pipefail + mkdir -p public-release + gh release download "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir public-release + + cat >/tmp/expected-release-assets <<'ASSETS' + SHA256SUMS + gpt-load-linux-amd64 + gpt-load-linux-arm64 + gpt-load-macos-amd64 + gpt-load-macos-arm64 + gpt-load-windows-amd64.exe + ASSETS + find public-release -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | + sort >/tmp/actual-release-assets + asset_count="$(wc -l /tmp/checksummed-release-assets + tail -n +2 /tmp/expected-release-assets >/tmp/expected-checksummed-release-assets + cmp /tmp/expected-checksummed-release-assets /tmp/checksummed-release-assets + sha256sum --check SHA256SUMS + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Log in to GHCR for manifest verification uses: docker/login-action@v4 @@ -700,6 +569,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub for manifest verification + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Verify published image manifests shell: bash run: | @@ -717,9 +592,15 @@ jobs: test -n "${exact_digest}" if [[ "${repository}" == ghcr.io/* ]]; then ghcr_exact_digest="${exact_digest}" + latest_image="ghcr.io/tbphp/gpt-load:latest" + expected_latest_digest="${{ needs.capture-channel-baseline.outputs.ghcr_latest_digest }}" else dockerhub_exact_digest="${exact_digest}" + latest_image="tbphp/gpt-load:latest" + expected_latest_digest="${{ needs.capture-channel-baseline.outputs.dockerhub_latest_digest }}" fi + latest_digest="$(.github/scripts/release-image-digest.sh "${latest_image}")" + test "${latest_digest}" = "${expected_latest_digest}" if [[ "${{ needs.validate-tag.outputs.prerelease }}" == "false" ]]; then for alias in \ @@ -735,3 +616,19 @@ jobs: fi done test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}" + + - name: Run full runtime smoke for both published exact images + shell: bash + env: + RELEASE_VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + for source_and_suffix in \ + "ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}|ghcr" \ + "tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}|dockerhub"; do + source_image="${source_and_suffix%%|*}" + smoke_suffix="${source_and_suffix##*|}" + RELEASE_SMOKE_SOURCE_IMAGE="${source_image}" \ + RELEASE_SMOKE_SUFFIX="${GITHUB_RUN_ID}-${smoke_suffix}" \ + .github/scripts/release-docker-smoke.sh + done diff --git a/README.md b/README.md index 8ada16ef..584b337c 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ cp .env.example .env docker compose config ``` -Continue only if the resolved configuration uses image `ghcr.io/tbphp/gpt-load:2`, sets `DATA_DIR=/app/data` and `DATABASE_DSN=/app/data/gpt-load.db`, and mounts a named volume at `/app/data`. If the checkout still resolves to `latest` or a host bind mount, the later T18 container closeout has not landed. Do not use that Compose file for a 2.0 production deployment, and do not substitute `latest`. +Continue only if the resolved configuration uses image `ghcr.io/tbphp/gpt-load:2`, sets `DATA_DIR=/app/data` and `DATABASE_DSN=/app/data/gpt-load.db`, and mounts a named volume at `/app/data`. If it resolves to `latest` or a host bind mount, do not use that Compose file for a 2.0 production deployment, and do not substitute `latest`. After those preconditions are met: diff --git a/README_CN.md b/README_CN.md index 8f16a56a..c0e0c0b7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -73,7 +73,7 @@ cp .env.example .env docker compose config ``` -只有当解析结果满足以下条件时,才继续:image 为 `ghcr.io/tbphp/gpt-load:2`,`DATA_DIR=/app/data`,`DATABASE_DSN=/app/data/gpt-load.db`,且 `/app/data` 使用 named volume。若当前 checkout 仍解析为 `latest` 或 host bind mount,表示后续 T18 容器收口尚未落地;不要把该 Compose 文件用于 2.0 生产部署,也不要自行改用 `latest`。 +只有当解析结果满足以下条件时,才继续:image 为 `ghcr.io/tbphp/gpt-load:2`,`DATA_DIR=/app/data`,`DATABASE_DSN=/app/data/gpt-load.db`,且 `/app/data` 使用 named volume。若解析结果仍是 `latest` 或 host bind mount,不要把该 Compose 文件用于 2.0 生产部署,也不要自行改用 `latest`。 满足前置条件后: diff --git a/README_JP.md b/README_JP.md index aa74d307..e7724f54 100644 --- a/README_JP.md +++ b/README_JP.md @@ -73,7 +73,7 @@ cp .env.example .env docker compose config ``` -解決後の設定でimageが`ghcr.io/tbphp/gpt-load:2`、`DATA_DIR=/app/data`、`DATABASE_DSN=/app/data/gpt-load.db`となり、`/app/data`にnamed volumeがマウントされる場合だけ続行してください。現在のcheckoutがまだ`latest`またはhost bind mountに解決される場合、後続のT18コンテナ作業は未反映です。そのComposeファイルを2.0の本番デプロイに使用せず、`latest`で代用しないでください。 +解決後の設定でimageが`ghcr.io/tbphp/gpt-load:2`、`DATA_DIR=/app/data`、`DATABASE_DSN=/app/data/gpt-load.db`となり、`/app/data`にnamed volumeがマウントされる場合だけ続行してください。`latest`またはhost bind mountに解決される場合、そのComposeファイルを2.0の本番デプロイに使用せず、`latest`で代用しないでください。 前提条件を満たした後: diff --git a/docker-compose.yml b/docker-compose.yml index b1d8e9ab..6f7ccfa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: env_file: - .env environment: + PORT: ${PORT:-3001} DATA_DIR: /app/data DATABASE_DSN: /app/data/gpt-load.db restart: always diff --git a/internal/control/model_price.go b/internal/control/model_price.go index 6bc17654..a0698124 100644 --- a/internal/control/model_price.go +++ b/internal/control/model_price.go @@ -69,32 +69,32 @@ func (s *Service) ResetModelPrice(ctx context.Context, pattern string) error { }) } -func (s *Service) ListModelPrices(ctx context.Context) ([]modelPriceRuleResponse, error) { +func (s *Service) ListModelPrices(ctx context.Context) (modelPriceListResponse, error) { s.writeMu.RLock() defer s.writeMu.RUnlock() var rows []models.ModelPrice if err := s.db.WithContext(ctx).Order("pattern ASC").Find(&rows).Error; err != nil { - return nil, app_errors.ParseDBError(err) + return modelPriceListResponse{}, app_errors.ParseDBError(err) } - rules := pricing.BuiltinRules() + builtinRules := pricing.BuiltinRules() + sort.Slice(builtinRules, func(left, right int) bool { + return builtinRules[left].Pattern < builtinRules[right].Pattern + }) + result := modelPriceListResponse{ + PriceUnit: "usd_per_million_tokens", + Builtin: make([]modelPriceRuleResponse, 0, len(builtinRules)), + Overrides: make([]modelPriceRuleResponse, 0, len(rows)), + } + for _, rule := range builtinRules { + result.Builtin = append(result.Builtin, newModelPriceRuleResponse(rule)) + } for _, row := range rows { if row.Source != string(pricing.SourceUser) { - return nil, app_errors.ErrInternalServer + return modelPriceListResponse{}, app_errors.ErrInternalServer } - rules = append(rules, modelPriceRule(row)) - } - sort.Slice(rules, func(left, right int) bool { - if rules[left].Source != rules[right].Source { - return rules[left].Source < rules[right].Source - } - return rules[left].Pattern < rules[right].Pattern - }) - - result := make([]modelPriceRuleResponse, 0, len(rules)) - for _, rule := range rules { - result = append(result, newModelPriceRuleResponse(rule)) + result.Overrides = append(result.Overrides, newModelPriceRuleResponse(modelPriceRule(row))) } return result, nil } diff --git a/internal/control/model_prices_api.go b/internal/control/model_prices_api.go index 0eed5145..454beff7 100644 --- a/internal/control/model_prices_api.go +++ b/internal/control/model_prices_api.go @@ -30,6 +30,12 @@ type modelPriceRuleResponse struct { UpdatedAt time.Time `json:"updated_at"` } +type modelPriceListResponse struct { + PriceUnit string `json:"price_unit"` + Builtin []modelPriceRuleResponse `json:"builtin"` + Overrides []modelPriceRuleResponse `json:"overrides"` +} + type modelPriceUpsertRequest struct { input ModelPriceInput } diff --git a/internal/control/model_prices_api_test.go b/internal/control/model_prices_api_test.go index 14edae00..ab14ad70 100644 --- a/internal/control/model_prices_api_test.go +++ b/internal/control/model_prices_api_test.go @@ -41,39 +41,45 @@ func TestModelPriceAPIListsStableBuiltinAndUserRules(t *testing.T) { } var envelope struct { Code int `json:"code"` - Data []struct { - Pattern string `json:"pattern"` - Source string `json:"source"` - Prices map[string]*float64 `json:"prices"` - SourceURL *string `json:"source_url"` - UpdatedAt time.Time `json:"updated_at"` + Data struct { + PriceUnit string `json:"price_unit"` + Builtin []modelPriceListTestResponse `json:"builtin"` + Overrides []modelPriceListTestResponse `json:"overrides"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode GET response: %v", err) } - if envelope.Code != 0 || len(envelope.Data) < 3 { + if envelope.Code != 0 || envelope.Data.PriceUnit != "usd_per_million_tokens" || + len(envelope.Data.Builtin) < 1 || len(envelope.Data.Overrides) != 2 { t.Fatalf("GET envelope = %#v", envelope) } - assertModelPriceResponseSorted(t, envelope.Data) + assertModelPriceResponseSorted(t, envelope.Data.Builtin) + assertModelPriceResponseSorted(t, envelope.Data.Overrides) - var builtin, userA, userZ *struct { - Pattern string `json:"pattern"` - Source string `json:"source"` - Prices map[string]*float64 `json:"prices"` - SourceURL *string `json:"source_url"` - UpdatedAt time.Time `json:"updated_at"` - } - for index := range envelope.Data { - item := &envelope.Data[index] + var builtin, userA, userZ *modelPriceListTestResponse + for index := range envelope.Data.Builtin { + item := &envelope.Data.Builtin[index] switch item.Pattern { case "gpt-5.6": builtin = item + default: + if item.Source != string(pricing.SourceBuiltin) { + t.Fatalf("builtin partition contains source %q", item.Source) + } + } + } + for index := range envelope.Data.Overrides { + item := &envelope.Data.Overrides[index] + switch item.Pattern { case "a-user": userA = item case "z-user": userZ = item } + if item.Source != string(pricing.SourceUser) { + t.Fatalf("override partition contains source %q", item.Source) + } } if builtin == nil || builtin.Source != string(pricing.SourceBuiltin) || builtin.SourceURL == nil || *builtin.SourceURL != "https://developers.openai.com/api/docs/pricing" || @@ -254,26 +260,21 @@ func assertModelPriceAPIError(t *testing.T, recorder *httptest.ResponseRecorder, } } -func assertModelPriceResponseSorted(t *testing.T, rules []struct { +type modelPriceListTestResponse struct { Pattern string `json:"pattern"` Source string `json:"source"` Prices map[string]*float64 `json:"prices"` SourceURL *string `json:"source_url"` UpdatedAt time.Time `json:"updated_at"` -}) { +} + +func assertModelPriceResponseSorted(t *testing.T, rules []modelPriceListTestResponse) { t.Helper() - patterns := make(map[string][]string) + patterns := make([]string, 0, len(rules)) for _, rule := range rules { - patterns[rule.Source] = append(patterns[rule.Source], rule.Pattern) + patterns = append(patterns, rule.Pattern) } - for source, values := range patterns { - if !sort.StringsAreSorted(values) { - t.Fatalf("%s patterns are not sorted: %#v", source, values) - } - } - for index := 1; index < len(rules); index++ { - if rules[index-1].Source > rules[index].Source { - t.Fatalf("sources are not stable: %#v then %#v", rules[index-1], rules[index]) - } + if !sort.StringsAreSorted(patterns) { + t.Fatalf("patterns are not sorted: %#v", patterns) } } diff --git a/internal/control/request_logs.go b/internal/control/request_logs.go index 0e6e920f..c0d844fc 100644 --- a/internal/control/request_logs.go +++ b/internal/control/request_logs.go @@ -416,15 +416,12 @@ type requestLogUsageCostResponse struct { } func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostResponse, error) { - switch record.UsageState { - case usage.StateComplete, usage.StatePartial, usage.StateMissing, usage.StateNotApplicable: - default: - return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage state: invalid value") - } - switch record.CostState { - case pricing.CostStatePriced, pricing.CostStateUnpriced, pricing.CostStateNotApplicable: - default: - return requestLogUsageCostResponse{}, fmt.Errorf("map request log cost state: invalid value") + if err := requestlog.ValidateUsageCostState( + record.UsageState, + record.CostState, + record.EstimatedCostUSD, + ); err != nil { + return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage/cost: %w", err) } for _, value := range []int64{ record.UncachedInputTokens, @@ -437,6 +434,9 @@ func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostRespon return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage tokens: unsafe value") } } + if uint64(record.GroupID) > uint64(maxSafeInteger) { + return requestLogUsageCostResponse{}, fmt.Errorf("map request log final Group ID: unsafe value") + } cost, err := mapEstimatedCostUSD(record.EstimatedCostUSD) if err != nil { return requestLogUsageCostResponse{}, err diff --git a/internal/control/request_logs_test.go b/internal/control/request_logs_test.go index 7ac5262b..a67b4893 100644 --- a/internal/control/request_logs_test.go +++ b/internal/control/request_logs_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -293,6 +294,38 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) { {name: "unsafe token", mutate: func(record *requestlog.Record) { record.OutputTokens = maxSafeInteger + 1 }}, {name: "negative cost", mutate: func(record *requestlog.Record) { record.EstimatedCostUSD = -0.1 }}, {name: "infinite cost", mutate: func(record *requestlog.Record) { record.EstimatedCostUSD = math.Inf(1) }}, + { + name: "missing usage cannot be priced", + mutate: func(record *requestlog.Record) { + record.UsageState = usage.StateMissing + }, + }, + { + name: "not applicable usage cannot be unpriced", + mutate: func(record *requestlog.Record) { + record.UsageState = usage.StateNotApplicable + record.CostState = pricing.CostStateUnpriced + }, + }, + { + name: "unpriced cost must be zero", + mutate: func(record *requestlog.Record) { + record.CostState = pricing.CostStateUnpriced + record.EstimatedCostUSD = 0.1 + }, + }, + } + if strconv.IntSize == 64 { + tests = append(tests, struct { + name string + mutate func(*requestlog.Record) + }{ + name: "unsafe final Group ID", + mutate: func(record *requestlog.Record) { + unsafeGroupID := uint64(maxSafeInteger) + 1 + record.GroupID = uint(unsafeGroupID) + }, + }) } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -305,6 +338,84 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) { } } +func TestRequestLogUsageCostProjectionAcceptsMaximumSafeFinalGroupID(t *testing.T) { + if strconv.IntSize != 64 { + t.Skip("maximum JavaScript safe integer does not fit uint on this architecture") + } + safeGroupID := uint64(maxSafeInteger) + record := requestlog.Record{ + GroupID: uint(safeGroupID), UsageState: usage.StateComplete, + CostState: pricing.CostStatePriced, + } + result, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}) + if err != nil { + t.Fatalf("mapRequestLogListResponse() error = %v", err) + } + if len(result.Items) != 1 || result.Items[0].GroupID == nil || + uint64(*result.Items[0].GroupID) != safeGroupID { + t.Fatalf("projected final Group ID = %#v", result.Items) + } +} + +func TestRequestLogEndpointFailsClosedOnCorruptReaderUsageCost(t *testing.T) { + tests := []struct { + name string + mutate func(*requestlog.Record) + }{ + { + name: "invalid state matrix", + mutate: func(record *requestlog.Record) { + record.UsageState = usage.StateMissing + record.CostState = pricing.CostStatePriced + }, + }, + { + name: "unpriced nonzero cost", + mutate: func(record *requestlog.Record) { + record.CostState = pricing.CostStateUnpriced + record.EstimatedCostUSD = 0.01 + }, + }, + } + if strconv.IntSize == 64 { + tests = append(tests, struct { + name string + mutate func(*requestlog.Record) + }{ + name: "unsafe final Group ID", + mutate: func(record *requestlog.Record) { + unsafeGroupID := uint64(maxSafeInteger) + 1 + record.GroupID = uint(unsafeGroupID) + }, + }) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := requestlog.Record{ + RequestID: "00000000-0000-4000-8000-000000000605", + CompletedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + UsageState: usage.StateComplete, + CostState: pricing.CostStatePriced, + Attempts: []requestlog.Attempt{}, + } + test.mutate(&record) + reader := &recordingRequestLogReader{ + pages: []requestlog.Page{{Items: []requestlog.Record{record}}}, + } + recorder := performRequestLogRequest( + newRequestLogTestEngine(t, reader), + "test-auth-key", + "", + ) + if recorder.Code != http.StatusInternalServerError || + !strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") || + strings.Contains(strings.ToLower(recorder.Body.String()), "unsafe") { + t.Fatalf("corrupt response = %d %s", recorder.Code, recorder.Body.String()) + } + }) + } +} + func TestRequestLogEndpointRequiresManagementAuthentication(t *testing.T) { reader := &recordingRequestLogReader{} engine := newRequestLogTestEngine(t, reader) diff --git a/internal/control/usage.go b/internal/control/usage.go index 849101b4..0f5678c3 100644 --- a/internal/control/usage.go +++ b/internal/control/usage.go @@ -7,7 +7,10 @@ import ( "math" "net/url" "strconv" + "strings" "time" + "unicode" + "unicode/utf8" "github.com/gin-gonic/gin" @@ -27,17 +30,6 @@ type UsageStatReader interface { QueryUsage(context.Context, requestlog.UsageQuery) (requestlog.UsageReport, error) } -type usageRangeResponse struct { - From string `json:"from"` - To string `json:"to"` - Granularity requestlog.UsageGranularity `json:"granularity"` -} - -type usageFiltersResponse struct { - GroupID *uint `json:"group_id"` - Model string `json:"model"` -} - type usageAggregateResponse struct { RequestCount int64 `json:"request_count"` SuccessCount int64 `json:"success_count"` @@ -66,15 +58,25 @@ type usageBreakdownResponse struct { usageAggregateResponse } +type usageCollectionHealthResponse struct { + Scope string `json:"scope"` + DroppedTotal uint64 `json:"dropped_total"` + WriteFailureTotal uint64 `json:"write_failure_total"` + LastWriteFailureAt *time.Time `json:"last_write_failure_at"` +} + type usageResponse struct { - ObservedAt string `json:"observed_at"` - Range usageRangeResponse `json:"range"` - Filters usageFiltersResponse `json:"filters"` - Summary usageAggregateResponse `json:"summary"` - Series []usageSeriesResponse `json:"series"` - Breakdown []usageBreakdownResponse `json:"breakdown"` - BreakdownTruncated bool `json:"breakdown_truncated"` - RequestLog requestLogHealthResponse `json:"request_log"` + Range string `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + Timezone string `json:"timezone"` + From string `json:"from"` + To string `json:"to"` + ObservedAt string `json:"observed_at"` + Summary usageAggregateResponse `json:"summary"` + Series []usageSeriesResponse `json:"series"` + Breakdown []usageBreakdownResponse `json:"breakdown"` + BreakdownTruncated bool `json:"breakdown_truncated"` + CollectionHealth usageCollectionHealthResponse `json:"collection_health"` } func (service *Service) QueryUsage( @@ -151,7 +153,7 @@ func parseUsageQuery(rawQuery string, observedAt time.Time) (requestlog.UsageQue query.GroupID = &groupID } if value, ok := singleQueryValue(values, "model"); ok { - if value == "" { + if !validUsageModel(value) { return requestlog.UsageQuery{}, app_errors.ErrValidation } query.Model = value @@ -159,6 +161,19 @@ func parseUsageQuery(rawQuery string, observedAt time.Time) (requestlog.UsageQue return query, nil } +func validUsageModel(value string) bool { + if !utf8.ValidString(value) || len(value) < 1 || len(value) > 255 || + strings.TrimSpace(value) != value { + return false + } + for _, character := range value { + if unicode.IsControl(character) { + return false + } + } + return true +} + func parseUsageGroupID(value string) (uint, *app_errors.APIError) { parsed, err := strconv.ParseUint(value, 10, 64) if err != nil { @@ -182,22 +197,36 @@ func (service *Service) mapUsageResponse( if err != nil { return usageResponse{}, err } - requestLog, err := mapRequestLogHealth(service.requestLogStats.Stats()) - if err != nil { - return usageResponse{}, err + stats := service.requestLogStats.Stats() + if stats.DroppedTotal > uint64(maxSafeInteger) || + stats.WriteFailureTotal > uint64(maxSafeInteger) { + return usageResponse{}, fmt.Errorf("map usage collection health: unsafe counter") + } + rangeValue := usageRange24Hours + switch query.Granularity { + case requestlog.UsageGranularityHour: + case requestlog.UsageGranularityDay: + rangeValue = usageRange30Days + default: + return usageResponse{}, fmt.Errorf("map usage response: invalid granularity") } result := usageResponse{ - ObservedAt: observedAt.UTC().Format(time.RFC3339Nano), - Range: usageRangeResponse{ - From: query.From.UTC().Format(time.RFC3339Nano), To: query.To.UTC().Format(time.RFC3339Nano), - Granularity: query.Granularity, + Range: rangeValue, + Granularity: query.Granularity, + Timezone: "UTC", + From: query.From.UTC().Format(time.RFC3339Nano), + To: query.To.UTC().Format(time.RFC3339Nano), + ObservedAt: observedAt.UTC().Format(time.RFC3339Nano), + CollectionHealth: usageCollectionHealthResponse{ + Scope: "current_process", + DroppedTotal: stats.DroppedTotal, + WriteFailureTotal: stats.WriteFailureTotal, + LastWriteFailureAt: optionalUTC(stats.LastWriteFailureAt), }, - Filters: usageFiltersResponse{GroupID: query.GroupID, Model: query.Model}, Summary: summary, Series: make([]usageSeriesResponse, 0, len(report.Series)), Breakdown: make([]usageBreakdownResponse, 0, len(report.Breakdown)), BreakdownTruncated: report.BreakdownTruncated, - RequestLog: requestLog, } for _, point := range report.Series { aggregate, err := mapUsageAggregate(point.UsageAggregate) diff --git a/internal/control/usage_test.go b/internal/control/usage_test.go index c4f4e355..9341138c 100644 --- a/internal/control/usage_test.go +++ b/internal/control/usage_test.go @@ -7,7 +7,7 @@ import ( "math" "net/http" "net/http/httptest" - "strconv" + "net/url" "strings" "testing" "time" @@ -90,7 +90,11 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing now := time.Date(2026, time.July, 27, 12, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60)) reader := &recordingUsageStatReader{} engine, fixture := newUsageTestEngine(t, now, reader) - fixture.requestLogStats.value.PersistedTotal = 8 + fixture.requestLogStats.value.DroppedTotal = 2 + fixture.requestLogStats.value.WriteFailureTotal = 1 + fixture.requestLogStats.value.LastWriteFailureAt = time.Date( + 2026, time.July, 27, 3, 0, 0, 0, time.UTC, + ) recorder := performUsageRequest(engine, "test-auth-key", "") if recorder.Code != http.StatusOK { @@ -109,23 +113,51 @@ func TestUsageAPIDefaultsToFixedUTCAligned24HoursAndReturnsZeroArrays(t *testing var envelope struct { Code int `json:"code"` Data struct { - ObservedAt string `json:"observed_at"` - Series []json.RawMessage `json:"series"` - Breakdown []json.RawMessage `json:"breakdown"` - RequestLog struct { - PersistedTotal uint64 `json:"persisted_total"` - } `json:"request_log"` + Range string `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + Timezone string `json:"timezone"` + From string `json:"from"` + To string `json:"to"` + ObservedAt string `json:"observed_at"` + Series []json.RawMessage `json:"series"` + Breakdown []json.RawMessage `json:"breakdown"` + Health struct { + Scope string `json:"scope"` + DroppedTotal uint64 `json:"dropped_total"` + WriteFailureTotal uint64 `json:"write_failure_total"` + LastWriteFailureAt *time.Time `json:"last_write_failure_at"` + } `json:"collection_health"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response: %v", err) } - if envelope.Code != 0 || envelope.Data.ObservedAt != "2026-07-27T04:34:56.000000789Z" || + if envelope.Code != 0 || envelope.Data.Range != "24h" || + envelope.Data.Granularity != requestlog.UsageGranularityHour || + envelope.Data.Timezone != "UTC" || + envelope.Data.From != "2026-07-26T05:00:00Z" || + envelope.Data.To != "2026-07-27T05:00:00Z" || + envelope.Data.ObservedAt != "2026-07-27T04:34:56.000000789Z" || envelope.Data.Series == nil || len(envelope.Data.Series) != 0 || envelope.Data.Breakdown == nil || len(envelope.Data.Breakdown) != 0 || - envelope.Data.RequestLog.PersistedTotal != 8 { + envelope.Data.Health.Scope != "current_process" || + envelope.Data.Health.DroppedTotal != 2 || + envelope.Data.Health.WriteFailureTotal != 1 || + envelope.Data.Health.LastWriteFailureAt == nil || + envelope.Data.Health.LastWriteFailureAt.Format(time.RFC3339Nano) != "2026-07-27T03:00:00Z" { t.Fatalf("default usage envelope = %#v", envelope) } + var rawEnvelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &rawEnvelope); err != nil { + t.Fatalf("decode raw response: %v", err) + } + for _, forbidden := range []string{"filters", "request_log"} { + if _, exists := rawEnvelope.Data[forbidden]; exists { + t.Fatalf("usage response exposes forbidden %q field: %s", forbidden, recorder.Body.String()) + } + } } func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { @@ -162,7 +194,12 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { } var envelope struct { Data struct { - Summary struct { + Range string `json:"range"` + Granularity requestlog.UsageGranularity `json:"granularity"` + Timezone string `json:"timezone"` + From string `json:"from"` + To string `json:"to"` + Summary struct { TotalTokens int64 `json:"total_tokens"` EstimatedCostUSD json.Number `json:"estimated_cost_usd"` } `json:"summary"` @@ -181,7 +218,13 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response: %v", err) } - if envelope.Data.Summary.TotalTokens != 5 || envelope.Data.Summary.EstimatedCostUSD.String() != "0" || + if envelope.Data.Range != "30d" || + envelope.Data.Granularity != requestlog.UsageGranularityDay || + envelope.Data.Timezone != "UTC" || + envelope.Data.From != "2026-06-28T00:00:00Z" || + envelope.Data.To != "2026-07-28T00:00:00Z" || + envelope.Data.Summary.TotalTokens != 5 || + envelope.Data.Summary.EstimatedCostUSD.String() != "0" || len(envelope.Data.Series) != 1 || envelope.Data.Series[0].BucketStart != "2026-06-28T00:00:00Z" || envelope.Data.Series[0].TotalTokens != 5 || envelope.Data.Series[0].EstimatedCostUSD.String() != "1.12345678901" || len(envelope.Data.Breakdown) != 1 || envelope.Data.Breakdown[0].GroupID != 9 || @@ -190,6 +233,51 @@ func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) { } } +func TestUsageAPIValidatesModelAsUTF8BytesWithoutBoundaryWhitespaceOrControls(t *testing.T) { + reader := &recordingUsageStatReader{} + engine, _ := newUsageTestEngine( + t, + time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + reader, + ) + validModels := []string{ + "a", + strings.Repeat("a", 252) + "猫", + "model variant", + } + for _, model := range validModels { + recorder := performUsageRequest(engine, "test-auth-key", "model="+url.QueryEscape(model)) + if recorder.Code != http.StatusOK { + t.Fatalf("valid model %q response = %d %s", model, recorder.Code, recorder.Body.String()) + } + if got := reader.queries[len(reader.queries)-1].Model; got != model { + t.Fatalf("reader model = %q, want %q", got, model) + } + } + validCalls := len(reader.queries) + + invalidModels := []struct { + name string + query string + }{ + {name: "invalid UTF-8", query: "model=%FF"}, + {name: "leading whitespace", query: "model=" + url.QueryEscape(" model")}, + {name: "trailing whitespace", query: "model=" + url.QueryEscape("model\u3000")}, + {name: "embedded control", query: "model=" + url.QueryEscape("model\x00id")}, + {name: "DEL control", query: "model=" + url.QueryEscape("model\x7fid")}, + {name: "256 UTF-8 bytes", query: "model=" + url.QueryEscape(strings.Repeat("a", 253)+"猫")}, + } + for _, test := range invalidModels { + t.Run(test.name, func(t *testing.T) { + recorder := performUsageRequest(engine, "test-auth-key", test.query) + assertUsageErrorCode(t, recorder, "VALIDATION_FAILED") + }) + } + if len(reader.queries) != validCalls { + t.Fatalf("reader calls = %d, want %d after invalid models", len(reader.queries), validCalls) + } +} + func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) { reader := &recordingUsageStatReader{} engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader) @@ -241,29 +329,8 @@ func TestUsageAPIRejectsUnsafeProcessStatsWithoutLeakingCause(t *testing.T) { name string mutate func(*requestlog.Stats) }{ - {name: "enqueued", mutate: func(stats *requestlog.Stats) { stats.EnqueuedTotal = uint64(maxSafeInteger) + 1 }}, - {name: "persisted", mutate: func(stats *requestlog.Stats) { stats.PersistedTotal = uint64(maxSafeInteger) + 1 }}, - {name: "dropped not running", mutate: func(stats *requestlog.Stats) { stats.DroppedNotRunningTotal = uint64(maxSafeInteger) + 1 }}, - {name: "dropped queue full", mutate: func(stats *requestlog.Stats) { stats.DroppedQueueFullTotal = uint64(maxSafeInteger) + 1 }}, - {name: "dropped stopping", mutate: func(stats *requestlog.Stats) { stats.DroppedStoppingTotal = uint64(maxSafeInteger) + 1 }}, - {name: "dropped persist failed", mutate: func(stats *requestlog.Stats) { stats.DroppedPersistFailedTotal = uint64(maxSafeInteger) + 1 }}, - {name: "dropped shutdown", mutate: func(stats *requestlog.Stats) { stats.DroppedShutdownTotal = uint64(maxSafeInteger) + 1 }}, {name: "dropped total", mutate: func(stats *requestlog.Stats) { stats.DroppedTotal = uint64(maxSafeInteger) + 1 }}, {name: "write failure", mutate: func(stats *requestlog.Stats) { stats.WriteFailureTotal = uint64(maxSafeInteger) + 1 }}, - {name: "retention delete failure", mutate: func(stats *requestlog.Stats) { stats.RetentionDeleteFailureTotal = uint64(maxSafeInteger) + 1 }}, - {name: "negative queue depth", mutate: func(stats *requestlog.Stats) { stats.QueueDepth = -1 }}, - } - if strconv.IntSize == 64 { - unsafeQueueCapacity := maxSafeInteger + 1 - tests = append(tests, struct { - name string - mutate func(*requestlog.Stats) - }{ - name: "unsafe queue capacity", - mutate: func(stats *requestlog.Stats) { - stats.QueueCapacity = int(unsafeQueueCapacity) - }, - }) } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/requestlog/query.go b/internal/requestlog/query.go index d4026697..cd90cd26 100644 --- a/internal/requestlog/query.go +++ b/internal/requestlog/query.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" "gpt-load/internal/pricing" "gpt-load/internal/protocol" @@ -135,15 +134,12 @@ func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) { } func validateRequestLogUsageCost(row models.RequestLog) error { - switch usage.State(row.UsageState) { - case usage.StateComplete, usage.StatePartial, usage.StateMissing, usage.StateNotApplicable: - default: - return fmt.Errorf("decode request log usage state: invalid value") - } - switch pricing.CostState(row.CostState) { - case pricing.CostStatePriced, pricing.CostStateUnpriced, pricing.CostStateNotApplicable: - default: - return fmt.Errorf("decode request log cost state: invalid value") + if err := ValidateUsageCostState( + usage.State(row.UsageState), + pricing.CostState(row.CostState), + row.Cost, + ); err != nil { + return fmt.Errorf("decode request log usage/cost: %w", err) } for _, value := range []int64{ row.InputTokens, @@ -156,9 +152,6 @@ func validateRequestLogUsageCost(row models.RequestLog) error { return fmt.Errorf("decode request log usage tokens: negative value") } } - if row.Cost < 0 || math.IsNaN(row.Cost) || math.IsInf(row.Cost, 0) { - return fmt.Errorf("decode request log cost: invalid value") - } return nil } diff --git a/internal/requestlog/query_test.go b/internal/requestlog/query_test.go index c5d6dd79..e89d421a 100644 --- a/internal/requestlog/query_test.go +++ b/internal/requestlog/query_test.go @@ -81,6 +81,41 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { {name: "negative cost", mutate: func(row *models.RequestLog) { row.Cost = -0.01 }}, {name: "infinite cost", mutate: func(row *models.RequestLog) { row.Cost = math.Inf(1) }}, {name: "not a number cost", mutate: func(row *models.RequestLog) { row.Cost = math.NaN() }}, + { + name: "missing usage cannot be priced", + mutate: func(row *models.RequestLog) { + row.UsageState = string(usage.StateMissing) + row.CostState = string(pricing.CostStatePriced) + }, + }, + { + name: "not applicable usage cannot be unpriced", + mutate: func(row *models.RequestLog) { + row.UsageState = string(usage.StateNotApplicable) + row.CostState = string(pricing.CostStateUnpriced) + }, + }, + { + name: "complete usage cannot have not applicable cost", + mutate: func(row *models.RequestLog) { + row.CostState = string(pricing.CostStateNotApplicable) + }, + }, + { + name: "unpriced cost must be zero", + mutate: func(row *models.RequestLog) { + row.CostState = string(pricing.CostStateUnpriced) + row.Cost = 0.01 + }, + }, + { + name: "not applicable cost must be zero", + mutate: func(row *models.RequestLog) { + row.UsageState = string(usage.StateNotApplicable) + row.CostState = string(pricing.CostStateNotApplicable) + row.Cost = 0.01 + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -93,6 +128,48 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) { } } +func TestDecodeRequestLogRowsAcceptsApprovedUsageCostMatrix(t *testing.T) { + tests := []struct { + name string + usageState usage.State + costState pricing.CostState + cost float64 + }{ + {name: "complete priced", usageState: usage.StateComplete, costState: pricing.CostStatePriced, cost: 0.01}, + {name: "complete unpriced", usageState: usage.StateComplete, costState: pricing.CostStateUnpriced}, + {name: "partial priced", usageState: usage.StatePartial, costState: pricing.CostStatePriced, cost: 0.01}, + {name: "partial unpriced", usageState: usage.StatePartial, costState: pricing.CostStateUnpriced}, + {name: "missing unpriced", usageState: usage.StateMissing, costState: pricing.CostStateUnpriced}, + { + name: "not applicable", + usageState: usage.StateNotApplicable, + costState: pricing.CostStateNotApplicable, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + row := models.RequestLog{ + ID: "00000000-0000-4000-8000-000000000604", + CreatedAt: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), + Protocol: string(protocol.OpenAI), + Status: string(telemetry.RequestStatusSuccess), + UsageState: string(test.usageState), + CostState: string(test.costState), + Cost: test.cost, + Attempts: models.JSON(`[]`), + } + records, err := decodeRequestLogRows([]models.RequestLog{row}) + if err != nil { + t.Fatalf("decodeRequestLogRows() error = %v", err) + } + if len(records) != 1 || records[0].UsageState != test.usageState || + records[0].CostState != test.costState || records[0].EstimatedCostUSD != test.cost { + t.Fatalf("records = %#v", records) + } + }) + } +} + func TestServiceListUsesStableKeysetCursor(t *testing.T) { db := openRequestLogQueryDB(t) service := newRequestLogTestService(db) diff --git a/internal/requestlog/usage_cost.go b/internal/requestlog/usage_cost.go new file mode 100644 index 00000000..40542226 --- /dev/null +++ b/internal/requestlog/usage_cost.go @@ -0,0 +1,36 @@ +package requestlog + +import ( + "fmt" + "math" + + "gpt-load/internal/pricing" + "gpt-load/internal/usage" +) + +func ValidateUsageCostState( + usageState usage.State, + costState pricing.CostState, + cost float64, +) error { + if cost < 0 || math.IsNaN(cost) || math.IsInf(cost, 0) { + return fmt.Errorf("invalid estimated cost") + } + validCombination := false + switch usageState { + case usage.StateComplete, usage.StatePartial: + validCombination = costState == pricing.CostStatePriced || + costState == pricing.CostStateUnpriced + case usage.StateMissing: + validCombination = costState == pricing.CostStateUnpriced + case usage.StateNotApplicable: + validCombination = costState == pricing.CostStateNotApplicable + } + if !validCombination { + return fmt.Errorf("invalid usage and cost state combination") + } + if costState != pricing.CostStatePriced && cost != 0 { + return fmt.Errorf("unpriced or not-applicable cost must be zero") + } + return nil +} diff --git a/internal/webui/container_contract_test.go b/internal/webui/container_contract_test.go index 371a5bf6..248a29c6 100644 --- a/internal/webui/container_contract_test.go +++ b/internal/webui/container_contract_test.go @@ -2,12 +2,67 @@ package webui import ( "encoding/json" + "os" "os/exec" "path/filepath" "strings" "testing" ) +func TestComposeShellPortOverridesDotEnvEverywhere(t *testing.T) { + projectDir := t.TempDir() + if err := os.WriteFile( + filepath.Join(projectDir, "docker-compose.yml"), + []byte(readRepositoryFile(t, "docker-compose.yml")), + 0o600, + ); err != nil { + t.Fatalf("write temporary Compose file: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, ".env"), []byte("PORT=3001\n"), 0o600); err != nil { + t.Fatalf("write temporary .env: %v", err) + } + + command := exec.Command( + "docker", "compose", "config", "--no-env-resolution", "--format", "json", + ) + command.Dir = projectDir + command.Env = append(os.Environ(), "PORT=41234") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("docker compose config: %v\n%s", err, output) + } + + var resolved struct { + Services map[string]struct { + Environment map[string]string `json:"environment"` + Healthcheck struct { + Test []string `json:"test"` + } `json:"healthcheck"` + Ports []struct { + Target int `json:"target"` + Published string `json:"published"` + } `json:"ports"` + } `json:"services"` + } + if err := json.Unmarshal(output, &resolved); err != nil { + t.Fatalf("decode docker compose config: %v\n%s", err, output) + } + + service := resolved.Services["gpt-load"] + if service.Environment["PORT"] != "41234" { + t.Fatalf("resolved application PORT = %q, want shell override 41234", service.Environment["PORT"]) + } + if len(service.Ports) != 1 || + service.Ports[0].Target != 41234 || + service.Ports[0].Published != "41234" { + t.Fatalf("resolved ports = %#v, want target/published 41234", service.Ports) + } + if len(service.Healthcheck.Test) != 2 || + !strings.Contains(service.Healthcheck.Test[1], "localhost:41234/health") { + t.Fatalf("resolved healthcheck = %#v, want container PORT 41234", service.Healthcheck.Test) + } +} + func TestDockerfileFinalStageDeclaresNonRootPersistentRuntime(t *testing.T) { content := readRepositoryFile(t, "Dockerfile") finalStageIndex := strings.LastIndex(content, "\nFROM ") diff --git a/internal/webui/workflow_test.go b/internal/webui/workflow_test.go index 9f6a6601..e4ae7fd5 100644 --- a/internal/webui/workflow_test.go +++ b/internal/webui/workflow_test.go @@ -128,6 +128,19 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) { } } +func TestReleaseWorkflowDoesNotRequireUntrackedAgentInstructionFiles(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + verifyJob := workflowJobBlock(t, content, "verify-and-build-web") + if !strings.Contains(verifyJob, "git diff --check") { + t.Fatal("release verification does not run git diff --check") + } + for _, untracked := range []string{"AGENTS.md", "CLAUDE.md"} { + if strings.Contains(verifyJob, untracked) { + t.Fatalf("release verification requires untracked %s", untracked) + } + } +} + func TestReleaseWorkflowBuildsOneWebDistAndFiveVersionedBinaries(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/release.yml") if count := strings.Count(content, "uses: ./.github/actions/web-ci"); count != 1 { @@ -252,6 +265,17 @@ func TestReleaseWorkflowIncludesCompleteS5NotesAndCSPWithinE2E(t *testing.T) { func TestReleaseWorkflowVerifiesDownloadedNativeChecksumsAndGeneratedKeys(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/release.yml") nativeJob := workflowJobBlock(t, content, "native-artifact-smoke") + for _, scriptPath := range []string{ + ".github/scripts/release-native-smoke.sh", + ".github/scripts/release-native-smoke.ps1", + } { + if !strings.Contains(nativeJob, scriptPath) { + t.Fatalf("native smoke does not invoke %s:\n%s", scriptPath, nativeJob) + } + } + nativeImplementation := nativeJob + + readRepositoryFile(t, ".github/scripts/release-native-smoke.sh") + + readRepositoryFile(t, ".github/scripts/release-native-smoke.ps1") for _, required := range []string{ "name: release-assets", "SHA256SUMS", @@ -273,18 +297,18 @@ func TestReleaseWorkflowVerifiesDownloadedNativeChecksumsAndGeneratedKeys(t *tes "$process.ExitCode -ne 0", "if (-not $process.HasExited)", } { - if !strings.Contains(nativeJob, required) { - t.Fatalf("native smoke does not contain %q:\n%s", required, nativeJob) + if !strings.Contains(nativeImplementation, required) { + t.Fatalf("native smoke does not contain %q", required) } } - if strings.Contains(nativeJob, "AUTH_KEY=release-native-smoke") || - strings.Contains(nativeJob, `$env:AUTH_KEY = "release-native-smoke"`) { + if strings.Contains(nativeImplementation, "AUTH_KEY=release-native-smoke") || + strings.Contains(nativeImplementation, `$env:AUTH_KEY = "release-native-smoke"`) { t.Fatal("native smoke bypasses generated auth.key with an explicit AUTH_KEY") } - if strings.Contains(nativeJob, "$process = Start-Process") { + if strings.Contains(nativeImplementation, "$process = Start-Process") { t.Fatal("Windows native smoke uses Start-Process without a new console process group") } - if strings.Contains(nativeJob, "CREATE_NEW_CONSOLE") { + if strings.Contains(nativeImplementation, "CREATE_NEW_CONSOLE") { t.Fatal("Windows native smoke gives the child a separate console that cannot receive the targeted CTRL_BREAK") } } @@ -381,6 +405,163 @@ func TestReleaseWorkflowPostPublishVerifiesAliasesAndExactDigests(t *testing.T) } } +func TestReleaseWorkflowPostPublishDownloadsExactAssetsAndRunsFiveNativeSmokes(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + inventoryJob := workflowJobBlock(t, content, "post-publish-verify") + for _, required := range []string{ + "gh release download", + "public-release", + "SHA256SUMS", + "sha256sum --check", + "asset_count", + `test "${asset_count}" = "6"`, + } { + if !strings.Contains(inventoryJob, required) { + t.Fatalf("post-publish asset inventory does not contain %q:\n%s", required, inventoryJob) + } + } + + nativeJob := workflowJobBlock(t, content, "post-publish-native-smoke") + for _, required := range []string{ + "publish-github", + "publish-images", + "ubuntu-24.04", + "ubuntu-24.04-arm", + "macos-15-intel", + "macos-15", + "windows-latest", + "gpt-load-linux-amd64", + "gpt-load-linux-arm64", + "gpt-load-macos-amd64", + "gpt-load-macos-arm64", + "gpt-load-windows-amd64.exe", + "gh release download", + ".github/scripts/release-native-smoke.sh", + ".github/scripts/release-native-smoke.ps1", + } { + if !strings.Contains(nativeJob, required) { + t.Fatalf("post-publish native smoke does not contain %q:\n%s", required, nativeJob) + } + } +} + +func TestReleaseWorkflowRunsBothPublishedImagesAndPreservesLatest(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + snapshotJob := workflowJobBlock(t, content, "capture-channel-baseline") + for _, required := range []string{ + "ghcr_latest_digest", + "dockerhub_latest_digest", + "ghcr.io/tbphp/gpt-load:latest", + "tbphp/gpt-load:latest", + ".github/scripts/release-image-digest.sh", + } { + if !strings.Contains(snapshotJob, required) { + t.Fatalf("latest digest snapshot does not contain %q:\n%s", required, snapshotJob) + } + } + + imagePublication := workflowJobBlock(t, content, "publish-images") + if !strings.Contains(imagePublication, "capture-channel-baseline") { + t.Fatal("image publication is not ordered after the latest digest snapshot") + } + + postPublish := workflowJobBlock(t, content, "post-publish-verify") + for _, required := range []string{ + "capture-channel-baseline", + "RELEASE_SMOKE_SOURCE_IMAGE", + `ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}`, + `tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}`, + ".github/scripts/release-docker-smoke.sh", + ".github/scripts/release-image-digest.sh", + "ghcr_latest_digest", + "dockerhub_latest_digest", + } { + if !strings.Contains(postPublish, required) { + t.Fatalf("published image runtime verification does not contain %q:\n%s", required, postPublish) + } + } + for name, block := range map[string]string{ + "capture-channel-baseline": snapshotJob, + "post-publish-verify": postPublish, + } { + for _, required := range []string{ + "Log in to Docker Hub", + "username: ${{ secrets.DOCKERHUB_USERNAME }}", + "password: ${{ secrets.DOCKERHUB_TOKEN }}", + } { + if !strings.Contains(block, required) { + t.Fatalf("%s does not contain authenticated Docker Hub read %q", name, required) + } + } + } +} + +func TestReleaseImageDigestFailsClosedOnOperationalInspectionErrors(t *testing.T) { + for _, message := range []string{"rate limit exceeded", "docker: command not found"} { + t.Run(message, func(t *testing.T) { + script := filepath.Join("..", "..", ".github", "scripts", "release-image-digest.sh") + fakeBin := t.TempDir() + fakeDocker := filepath.Join(fakeBin, "docker") + if err := os.WriteFile( + fakeDocker, + []byte("#!/usr/bin/env sh\nprintf '"+message+"\\n' >&2\nexit 1\n"), + 0o700, + ); err != nil { + t.Fatalf("write fake docker: %v", err) + } + command := exec.Command("bash", script, "example.test/gpt-load:latest") + command.Env = append(os.Environ(), "PATH="+fakeBin+":"+os.Getenv("PATH")) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("operational inspect failure returned success: %s", output) + } + if !strings.Contains(string(output), message) { + t.Fatalf("operational inspect failure output = %q", output) + } + }) + } +} + +func TestReleaseImageDigestReturnsAbsentOnlyForMissingManifest(t *testing.T) { + script := filepath.Join("..", "..", ".github", "scripts", "release-image-digest.sh") + fakeBin := t.TempDir() + fakeDocker := filepath.Join(fakeBin, "docker") + if err := os.WriteFile( + fakeDocker, + []byte("#!/usr/bin/env sh\nprintf 'manifest unknown\\n' >&2\nexit 1\n"), + 0o700, + ); err != nil { + t.Fatalf("write fake docker: %v", err) + } + command := exec.Command("bash", script, "example.test/gpt-load:latest") + command.Env = append(os.Environ(), "PATH="+fakeBin+":"+os.Getenv("PATH")) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("missing manifest returned error: %v\n%s", err, output) + } + if strings.TrimSpace(string(output)) != "absent" { + t.Fatalf("missing manifest output = %q, want absent", output) + } +} + +func TestReleaseDockerSmokeCanUsePublishedSourceWithoutDeletingIt(t *testing.T) { + script := readRepositoryFile(t, ".github/scripts/release-docker-smoke.sh") + for _, required := range []string{ + "RELEASE_SMOKE_SOURCE_IMAGE", + `docker pull --platform "${platform}" "${source_image}"`, + `docker image tag "${source_image}" "${image}"`, + `docker image rm "${image}"`, + "prices.data.overrides.some", + } { + if !strings.Contains(script, required) { + t.Fatalf("release Docker smoke source-image mode does not contain %q", required) + } + } + if strings.Contains(script, `docker image rm "${source_image}"`) { + t.Fatal("release Docker smoke deletes the externally owned source image") + } +} + func TestReleaseWorkflowRemovesObsoletePublishers(t *testing.T) { for _, name := range []string{ ".github/workflows/docker-build.yml", @@ -394,6 +575,15 @@ func TestReleaseWorkflowRemovesObsoletePublishers(t *testing.T) { } } +func TestReadmesDoNotExposeInternalReleaseTaskNames(t *testing.T) { + for _, name := range []string{"README.md", "README_CN.md", "README_JP.md"} { + content := readRepositoryFile(t, name) + if strings.Contains(content, "T18") { + t.Fatalf("%s exposes the completed internal T18 task name", name) + } + } +} + func TestDockerfileCopiesWebInstallPolicyBeforeInstalling(t *testing.T) { content := readRepositoryFile(t, "Dockerfile") copyInputs := "COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./web/" diff --git a/web/e2e/business-flows.spec.ts b/web/e2e/business-flows.spec.ts index 50ddc64b..13d5625c 100644 --- a/web/e2e/business-flows.spec.ts +++ b/web/e2e/business-flows.spec.ts @@ -132,10 +132,23 @@ test('critical management journey works through the embedded binary', async ({ p const addDrawer = page.getByRole('dialog', { name: 'Add model price override' }) await addDrawer.getByLabel('Model pattern').fill('*') await addDrawer.getByLabel('Uncached input').fill('1') - await expect(addDrawer.getByRole('button', { name: 'Save override' })).toBeDisabled() - await addDrawer.getByLabel('I understand that * shadows every built-in price rule.').check() + await expect(addDrawer).toContainText('* shadows every built-in price rule') + await expect(addDrawer.getByRole('button', { name: 'Save override' })).toBeEnabled() await addDrawer.getByRole('button', { name: 'Save override' }).click() + const globalPriceDialog = page.getByRole('dialog', { + name: 'Create a global user price override?', + }) + await expect(globalPriceDialog).toContainText( + 'takes precedence over every built-in exact and prefix rule', + ) + await expect(globalPriceDialog).toContainText('Unset price slots do not fall back') + await expect(globalPriceDialog).toContainText('future completed or Emit requests') + await expect(globalPriceDialog).toContainText( + 'Reset restores the remaining user and built-in rules', + ) + await globalPriceDialog.getByRole('button', { name: 'Create global override' }).click() + const overrideRow = page.locator('[data-test="override-price-row-0"]') await expect(overrideRow).toContainText('*') await expect(overrideRow).toContainText('$1') @@ -145,8 +158,8 @@ test('critical management journey works through the embedded binary', async ({ p const editDrawer = page.getByRole('dialog', { name: 'Edit model price override' }) await expect(editDrawer.getByLabel('Model pattern')).toHaveAttribute('readonly', '') await editDrawer.getByLabel('Output').fill('2') - await editDrawer.getByLabel('I understand that * shadows every built-in price rule.').check() await editDrawer.getByRole('button', { name: 'Save override' }).click() + await globalPriceDialog.getByRole('button', { name: 'Create global override' }).click() await expect(editPrice).toBeFocused() await expect(page.locator('[data-test="override-price-row-0"]')).toContainText('$2') @@ -222,8 +235,7 @@ test('critical management journey works through the embedded binary', async ({ p await expect(page.locator('[data-test="usage-quality-missing"]')).toContainText('1') await expect(page.locator('[data-test="usage-quality-partial"]')).toContainText('1') await expect(page.locator('[data-test="usage-quality-unpriced"]')).toContainText('3') - await expect(page.locator('[data-test="usage-kpi-cost"]')).toContainText('Unknown') - await expect(page.locator('[data-test="usage-kpi-cost"]')).not.toContainText('$0') + await expect(page.locator('[data-test="usage-kpi-cost"]')).toContainText('$0.00 + unknown') await expect(page.locator('[data-test="usage-kpi-cost"]')).not.toContainText('Free') await expect(page.locator('[data-test="usage-scope"]')).toContainText('current process') await expect(page.locator('[data-test="usage-prices-link"]')).toHaveAttribute( @@ -251,7 +263,7 @@ test('critical management journey works through the embedded binary', async ({ p await expect(page.locator('[data-test="home-usage-requests"]')).toContainText( String(expectedUsageRequests), ) - await expect(page.locator('[data-test="home-usage-cost"]')).toContainText('Unknown') + await expect(page.locator('[data-test="home-usage-cost"]')).toContainText('$0.00 + unknown') await expect(page.locator('[data-test="home-usage-tokens"]')).toContainText('Unknown') await expect(page.locator('[data-test="home-usage-tokens"]')).not.toContainText('0') await expect(page.locator('[data-test="home-usage-quality-missing"]')).toContainText( diff --git a/web/src/api/control/model-prices.test.ts b/web/src/api/control/model-prices.test.ts index 13a60e9c..8fa18ac8 100644 --- a/web/src/api/control/model-prices.test.ts +++ b/web/src/api/control/model-prices.test.ts @@ -42,36 +42,122 @@ const userRule = { describe('ModelPrice control API', () => { it('projects valid builtin and user rules while preserving explicit null and zero prices', async () => { const signal = new AbortController().signal - const request = vi.fn().mockResolvedValue([builtinRule, userRule]) - - await expect( - getModelPrices({ request: request as ApiClient['request'] }, signal), - ).resolves.toEqual({ + const response = { price_unit: 'usd_per_million_tokens', builtin: [builtinRule], overrides: [userRule], - }) + } as const + const request = vi.fn().mockResolvedValue(response) + + await expect( + getModelPrices({ request: request as ApiClient['request'] }, signal), + ).resolves.toEqual(response) expect(request).toHaveBeenCalledWith('/api/model-prices', { method: 'GET', signal }) }) it.each([ - ['non-array response', {}], - ['malformed rule', [{}]], - ['unknown source', [{ ...builtinRule, source: 'remote' }]], - ['malformed timestamp', [{ ...builtinRule, updated_at: 'tomorrow' }]], + ['legacy flat response', [builtinRule, userRule]], + [ + 'wrong price unit', + { + price_unit: 'usd_per_token', + builtin: [builtinRule], + overrides: [userRule], + }, + ], + ['missing builtin partition', { price_unit: 'usd_per_million_tokens', overrides: [userRule] }], + [ + 'malformed rule', + { price_unit: 'usd_per_million_tokens', builtin: [{}], overrides: [userRule] }, + ], + [ + 'unknown source', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, source: 'remote' }], + overrides: [userRule], + }, + ], + [ + 'user source in builtin partition', + { + price_unit: 'usd_per_million_tokens', + builtin: [userRule], + overrides: [], + }, + ], + [ + 'builtin source in override partition', + { + price_unit: 'usd_per_million_tokens', + builtin: [], + overrides: [builtinRule], + }, + ], + [ + 'malformed timestamp', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, updated_at: 'tomorrow' }], + overrides: [userRule], + }, + ], [ 'normalized invalid calendar timestamp', - [{ ...builtinRule, updated_at: '2026-02-30T00:00:00Z' }], + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, updated_at: '2026-02-30T00:00:00Z' }], + overrides: [userRule], + }, + ], + [ + 'invalid RFC3339 offset', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, updated_at: '2026-07-27T12:30:00+24:00' }], + overrides: [userRule], + }, + ], + [ + 'missing five-price key', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, prices: { ...prices, output: undefined } }], + overrides: [userRule], + }, ], - ['invalid RFC3339 offset', [{ ...builtinRule, updated_at: '2026-07-27T12:30:00+24:00' }]], - ['missing five-price key', [{ ...builtinRule, prices: { ...prices, output: undefined } }]], [ 'non-finite price', - [{ ...builtinRule, prices: { ...prices, output: Number.POSITIVE_INFINITY } }], + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, prices: { ...prices, output: Number.POSITIVE_INFINITY } }], + overrides: [userRule], + }, + ], + [ + 'negative price', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, prices: { ...prices, output: -0.01 } }], + overrides: [userRule], + }, + ], + [ + 'builtin without source URL', + { + price_unit: 'usd_per_million_tokens', + builtin: [{ ...builtinRule, source_url: null }], + overrides: [userRule], + }, + ], + [ + 'user with source URL', + { + price_unit: 'usd_per_million_tokens', + builtin: [builtinRule], + overrides: [{ ...userRule, source_url: 'https://example.test/pricing' }], + }, ], - ['negative price', [{ ...builtinRule, prices: { ...prices, output: -0.01 } }]], - ['builtin without source URL', [{ ...builtinRule, source_url: null }]], - ['user with source URL', [{ ...userRule, source_url: 'https://example.test/pricing' }]], ])('rejects %s', (_name, value) => { expect(() => projectModelPrices(value)).toThrow(InvalidResponseError) }) diff --git a/web/src/api/control/model-prices.ts b/web/src/api/control/model-prices.ts index ca6e7d25..b54ebede 100644 --- a/web/src/api/control/model-prices.ts +++ b/web/src/api/control/model-prices.ts @@ -110,16 +110,21 @@ function projectBuiltinSourceURL(value: unknown): string { } export function projectModelPrices(value: unknown): ModelPriceReportDto { - if (!Array.isArray(value)) throw new InvalidResponseError() - const builtin: ModelPriceRuleDto[] = [] - const overrides: ModelPriceRuleDto[] = [] - for (const item of value) { - const rule = projectRule(item) - if (rule.source === 'builtin') { - builtin.push(rule) - } else { - overrides.push(rule) - } + if ( + !isRecord(value) || + value.price_unit !== 'usd_per_million_tokens' || + !Array.isArray(value.builtin) || + !Array.isArray(value.overrides) + ) { + throw new InvalidResponseError() + } + const builtin = value.builtin.map(projectRule) + const overrides = value.overrides.map(projectRule) + if ( + builtin.some((rule) => rule.source !== 'builtin') || + overrides.some((rule) => rule.source !== 'user') + ) { + throw new InvalidResponseError() } return { price_unit: 'usd_per_million_tokens', builtin, overrides } } diff --git a/web/src/api/control/usage.test.ts b/web/src/api/control/usage.test.ts index 98ea9b37..7502b1aa 100644 --- a/web/src/api/control/usage.test.ts +++ b/web/src/api/control/usage.test.ts @@ -19,31 +19,20 @@ const aggregate = { unpriced_request_count: 3, } as const -const requestLog = { - enqueued_total: 12, - persisted_total: 10, - dropped_not_running_total: 1, - dropped_queue_full_total: 0, - dropped_stopping_total: 0, - dropped_persist_failed_total: 1, - dropped_shutdown_total: 0, +const collectionHealth = { + scope: 'current_process', dropped_total: 2, write_failure_total: 1, - retention_delete_failure_total: 0, - queue_depth: 2, - queue_capacity: 256, last_write_failure_at: null, - last_retention_failure_at: '2026-07-27T10:00:00Z', } as const const report = { + range: '24h', + granularity: 'hour', + timezone: 'UTC', + from: '2026-07-26T13:00:00Z', + to: '2026-07-27T13:00:00Z', observed_at: '2026-07-27T12:21:48Z', - range: { - from: '2026-07-26T13:00:00Z', - to: '2026-07-27T13:00:00Z', - granularity: 'hour', - }, - filters: { group_id: 7, model: 'gpt-5.6' }, summary: aggregate, series: [ { @@ -54,7 +43,7 @@ const report = { ], breakdown: [{ group_id: 7, model: 'gpt-5.6', ...aggregate }], breakdown_truncated: false, - request_log: requestLog, + collection_health: collectionHealth, } as const describe('Usage control API', () => { @@ -104,11 +93,10 @@ describe('Usage control API', () => { it('accepts an exact UTC day bucket inside the aligned 30-day window', () => { const dailyReport = { ...report, - range: { - from: '2026-06-28T00:00:00Z', - to: '2026-07-28T00:00:00Z', - granularity: 'day' as const, - }, + range: '30d' as const, + granularity: 'day' as const, + from: '2026-06-28T00:00:00Z', + to: '2026-07-28T00:00:00Z', series: [ { ...report.series[0], @@ -135,11 +123,11 @@ describe('Usage control API', () => { { ...report, summary: { ...aggregate, estimated_cost_usd: Number.POSITIVE_INFINITY } }, ], ['bad observed timestamp', { ...report, observed_at: 'tomorrow' }], - [ - 'bad range timestamp', - { ...report, range: { ...report.range, from: '2026-02-30T00:00:00Z' } }, - ], - ['unknown granularity', { ...report, range: { ...report.range, granularity: 'week' } }], + ['bad range timestamp', { ...report, from: '2026-02-30T00:00:00Z' }], + ['unknown range', { ...report, range: '7d' }], + ['unknown granularity', { ...report, granularity: 'week' }], + ['wrong timezone', { ...report, timezone: 'Asia/Shanghai' }], + ['range/granularity mismatch', { ...report, range: '30d' }], [ 'out-of-order buckets', { @@ -158,11 +146,8 @@ describe('Usage control API', () => { 'rolling response window without UTC-aligned boundaries', { ...report, - range: { - ...report.range, - from: '2026-07-26T12:21:48Z', - to: '2026-07-27T12:21:48Z', - }, + from: '2026-07-26T12:21:48Z', + to: '2026-07-27T12:21:48Z', series: [], }, ], @@ -170,10 +155,7 @@ describe('Usage control API', () => { 'aligned response window with the wrong bucket count', { ...report, - range: { - ...report.range, - from: '2026-07-26T14:00:00Z', - }, + from: '2026-07-26T14:00:00Z', series: [], }, ], @@ -234,11 +216,10 @@ describe('Usage control API', () => { { ...report, observed_at: '2026-07-27T12:21:48Z', - range: { - from: '2026-06-28T00:00:00Z', - to: '2026-07-28T00:00:00Z', - granularity: 'day', - }, + range: '30d', + granularity: 'day', + from: '2026-06-28T00:00:00Z', + to: '2026-07-28T00:00:00Z', series: [ { ...report.series[0], @@ -249,12 +230,39 @@ describe('Usage control API', () => { }, ], [ - 'breakdown outside filtered source scope', - { ...report, breakdown: [{ ...report.breakdown[0], group_id: 8 }] }, + 'unsafe breakdown Group', + { + ...report, + breakdown: [{ ...report.breakdown[0], group_id: Number.MAX_SAFE_INTEGER + 1 }], + }, ], [ - 'malformed request-log counter', - { ...report, request_log: { ...requestLog, queue_depth: -1 } }, + 'malformed collection-health scope', + { ...report, collection_health: { ...collectionHealth, scope: 'all_time' } }, + ], + [ + 'malformed collection-health counter', + { ...report, collection_health: { ...collectionHealth, dropped_total: -1 } }, + ], + [ + 'malformed collection-health timestamp', + { + ...report, + collection_health: { ...collectionHealth, last_write_failure_at: 'tomorrow' }, + }, + ], + [ + 'legacy private response shape', + { + observed_at: report.observed_at, + range: { from: report.from, to: report.to, granularity: report.granularity }, + filters: { group_id: 7, model: 'gpt-5.6' }, + summary: aggregate, + series: report.series, + breakdown: report.breakdown, + breakdown_truncated: false, + request_log: {}, + }, ], ])('rejects %s', (_name, value) => { expect(() => projectUsageReport(value)).toThrow(InvalidResponseError) diff --git a/web/src/api/control/usage.ts b/web/src/api/control/usage.ts index 5bd6ad85..15af78ca 100644 --- a/web/src/api/control/usage.ts +++ b/web/src/api/control/usage.ts @@ -1,8 +1,6 @@ import type { ApiClient } from '@/api/client' import { InvalidResponseError } from '@/api/errors' -import type { RequestLogHealthDto } from './types' - export interface UsageFilters { range: '24h' | '30d' group_id?: number @@ -26,14 +24,22 @@ export interface UsageAggregateDto { } export interface UsageReportDto { + range: '24h' | '30d' + granularity: 'hour' | 'day' + timezone: 'UTC' + from: string + to: string observed_at: string - range: { from: string; to: string; granularity: 'hour' | 'day' } - filters: { group_id: number | null; model: string } summary: UsageAggregateDto series: Array breakdown: Array breakdown_truncated: boolean - request_log: RequestLogHealthDto + collection_health: { + scope: 'current_process' + dropped_total: number + write_failure_total: number + last_write_failure_at: string | null + } } const aggregateKeys = [ @@ -51,21 +57,6 @@ const aggregateKeys = [ 'unpriced_request_count', ] as const -const requestLogCountKeys = [ - 'enqueued_total', - 'persisted_total', - 'dropped_not_running_total', - 'dropped_queue_full_total', - 'dropped_stopping_total', - 'dropped_persist_failed_total', - 'dropped_shutdown_total', - 'dropped_total', - 'write_failure_total', - 'retention_delete_failure_total', - 'queue_depth', - 'queue_capacity', -] as const - const hourMs = 60 * 60 * 1000 const dayMs = 24 * hourMs @@ -149,33 +140,45 @@ function projectAggregate(value: unknown): UsageAggregateDto { return value as unknown as UsageAggregateDto } -function projectRequestLog(value: unknown): RequestLogHealthDto { - if (!isRecord(value)) throw new InvalidResponseError() - for (const key of requestLogCountKeys) { - if (!isSafeNonNegativeInteger(value[key])) throw new InvalidResponseError() +function projectCollectionHealth(value: unknown): UsageReportDto['collection_health'] { + if ( + !isRecord(value) || + value.scope !== 'current_process' || + !isSafeNonNegativeInteger(value.dropped_total) || + !isSafeNonNegativeInteger(value.write_failure_total) || + (value.last_write_failure_at !== null && !isRFC3339Timestamp(value.last_write_failure_at)) + ) { + throw new InvalidResponseError() } - for (const key of ['last_write_failure_at', 'last_retention_failure_at'] as const) { - if (value[key] !== null && !isRFC3339Timestamp(value[key])) throw new InvalidResponseError() + return { + scope: 'current_process', + dropped_total: value.dropped_total, + write_failure_total: value.write_failure_total, + last_write_failure_at: value.last_write_failure_at, } - return value as unknown as RequestLogHealthDto } export function projectUsageReport(value: unknown): UsageReportDto { - if (!isRecord(value) || !isRecord(value.range) || !isRecord(value.filters)) { + if ( + !isRecord(value) || + (value.range !== '24h' && value.range !== '30d') || + (value.granularity !== 'hour' && value.granularity !== 'day') || + value.timezone !== 'UTC' || + (value.range === '24h' && value.granularity !== 'hour') || + (value.range === '30d' && value.granularity !== 'day') + ) { throw new InvalidResponseError() } const observedAt = timestamp(value.observed_at) - const rangeFrom = timestamp(value.range.from) - const rangeTo = timestamp(value.range.to) - const granularity = value.range.granularity - if (granularity !== 'hour' && granularity !== 'day') { - throw new InvalidResponseError() - } + const rangeFrom = timestamp(value.from) + const rangeTo = timestamp(value.to) + const range = value.range + const granularity = value.granularity const observedAtMs = timestampMs(observedAt) const rangeFromMs = timestampMs(rangeFrom) const rangeToMs = timestampMs(rangeTo) const bucketDurationMs = granularity === 'hour' ? hourMs : dayMs - const bucketCount = granularity === 'hour' ? 24 : 30 + const bucketCount = range === '24h' ? 24 : 30 if ( !isUTCAligned(rangeFromMs, granularity) || !isUTCAligned(rangeToMs, granularity) || @@ -185,11 +188,7 @@ export function projectUsageReport(value: unknown): UsageReportDto { ) { throw new InvalidResponseError() } - const filters = value.filters if ( - (filters.group_id !== null && !isSafeNonNegativeInteger(filters.group_id)) || - (filters.group_id !== null && filters.group_id === 0) || - typeof filters.model !== 'string' || !Array.isArray(value.series) || !Array.isArray(value.breakdown) || typeof value.breakdown_truncated !== 'boolean' @@ -227,23 +226,20 @@ export function projectUsageReport(value: unknown): UsageReportDto { ) { throw new InvalidResponseError() } - if ( - (filters.group_id !== null && item.group_id !== filters.group_id) || - (filters.model !== '' && item.model !== filters.model) - ) { - throw new InvalidResponseError() - } return { ...projectAggregate(item), group_id: item.group_id, model: item.model } }) return { + range, + granularity, + timezone: 'UTC', + from: rangeFrom, + to: rangeTo, observed_at: observedAt, - range: { from: rangeFrom, to: rangeTo, granularity }, - filters: { group_id: filters.group_id, model: filters.model }, summary: projectAggregate(value.summary), series, breakdown, breakdown_truncated: value.breakdown_truncated, - request_log: projectRequestLog(value.request_log), + collection_health: projectCollectionHealth(value.collection_health), } } diff --git a/web/src/features/home/HomeView.test.ts b/web/src/features/home/HomeView.test.ts index 4656d427..0da3bfbe 100644 --- a/web/src/features/home/HomeView.test.ts +++ b/web/src/features/home/HomeView.test.ts @@ -62,12 +62,11 @@ const healthFixture: RuntimeHealthDto = { const usageFixture: UsageReportDto = { observed_at: '2026-07-25T10:00:01Z', - range: { - from: '2026-07-24T11:00:00Z', - to: '2026-07-25T11:00:00Z', - granularity: 'hour', - }, - filters: { group_id: null, model: '' }, + range: '24h', + granularity: 'hour', + timezone: 'UTC', + from: '2026-07-24T11:00:00Z', + to: '2026-07-25T11:00:00Z', summary: { request_count: 4, success_count: 3, @@ -86,7 +85,12 @@ const usageFixture: UsageReportDto = { series: [], breakdown: [], breakdown_truncated: false, - request_log: healthFixture.request_log, + collection_health: { + scope: 'current_process', + dropped_total: healthFixture.request_log.dropped_total, + write_failure_total: healthFixture.request_log.write_failure_total, + last_write_failure_at: healthFixture.request_log.last_write_failure_at, + }, } async function mountHome(api: FakeApi, origin?: string, queryClient?: QueryClient) { diff --git a/web/src/features/home/UsageSummaryCard.test.ts b/web/src/features/home/UsageSummaryCard.test.ts index af5f8c11..3ae95ec8 100644 --- a/web/src/features/home/UsageSummaryCard.test.ts +++ b/web/src/features/home/UsageSummaryCard.test.ts @@ -26,32 +26,21 @@ const aggregate: UsageAggregateDto = { function usageReport(overrides: Partial = {}): UsageReportDto { return { + range: '24h', + granularity: 'hour', + timezone: 'UTC', + from: '2026-07-26T05:00:00Z', + to: '2026-07-27T05:00:00Z', observed_at: '2026-07-27T04:00:01Z', - range: { - from: '2026-07-26T05:00:00Z', - to: '2026-07-27T05:00:00Z', - granularity: 'hour', - }, - filters: { group_id: null, model: '' }, summary: { ...aggregate }, series: [], breakdown: [], breakdown_truncated: false, - request_log: { - enqueued_total: 20, - persisted_total: 18, - dropped_not_running_total: 0, - dropped_queue_full_total: 1, - dropped_stopping_total: 0, - dropped_persist_failed_total: 1, - dropped_shutdown_total: 0, + collection_health: { + scope: 'current_process', dropped_total: 2, write_failure_total: 1, - retention_delete_failure_total: 0, - queue_depth: 0, - queue_capacity: 100, last_write_failure_at: '2026-07-27T03:00:00Z', - last_retention_failure_at: null, }, ...overrides, } @@ -132,10 +121,11 @@ describe('UsageSummaryCard', () => { partial_count: 0, unpriced_request_count: 0, }, - request_log: { - ...usageReport().request_log, + collection_health: { + ...usageReport().collection_health, dropped_total: 0, write_failure_total: 0, + last_write_failure_at: null, }, }) const { wrapper } = await mountSummary(new UsageApi(report)) @@ -160,10 +150,11 @@ describe('UsageSummaryCard', () => { partial_count: 0, unpriced_request_count: 0, }, - request_log: { - ...usageReport().request_log, + collection_health: { + ...usageReport().collection_health, dropped_total: 0, write_failure_total: 0, + last_write_failure_at: null, }, }) const { wrapper } = await mountSummary(new UsageApi(report)) @@ -204,8 +195,9 @@ describe('UsageSummaryCard', () => { }) const { wrapper } = await mountSummary(new UsageApi(report)) - expect(wrapper.get('[data-test="home-usage-cost"]').text()).toContain('Unknown') - expect(wrapper.get('[data-test="home-usage-cost"]').text()).not.toMatch(/\$0|Free/) + expect(wrapper.get('[data-test="home-usage-cost"]').text()).toContain('$0.00') + expect(wrapper.get('[data-test="home-usage-cost"]').text()).toContain('unknown') + expect(wrapper.get('[data-test="home-usage-cost"]').text()).not.toContain('Free') expect(wrapper.get('[data-test="home-usage-quality-missing"]').text()).toBe('Usage missing 2') expect(wrapper.get('[data-test="home-usage-quality-partial"]').text()).toBe('Usage partial 3') expect(wrapper.get('[data-test="home-usage-quality-unpriced"]').text()).toBe('Cost unpriced 4') @@ -223,6 +215,21 @@ describe('UsageSummaryCard', () => { ) }) + it('keeps a positive sub-cent estimated cost visible', async () => { + const report = usageReport({ + summary: { + ...aggregate, + estimated_cost_usd: 0.00000049, + unpriced_request_count: 0, + }, + }) + const { wrapper } = await mountSummary(new UsageApi(report)) + + const displayedCost = wrapper.get('[data-test="home-usage-cost"] strong').text() + expect(displayedCost).toBe('$0.00000049') + expect(displayedCost).not.toBe('$0.00') + }) + it('shows a safe initial error without exposing the transport error', async () => { const { wrapper } = await mountSummary( new UsageApi(Promise.reject(new Error('usage-secret-canary'))), diff --git a/web/src/features/home/UsageSummaryCard.vue b/web/src/features/home/UsageSummaryCard.vue index 8495e078..c16a93d4 100644 --- a/web/src/features/home/UsageSummaryCard.vue +++ b/web/src/features/home/UsageSummaryCard.vue @@ -12,6 +12,7 @@ import InlineFeedback from '@/components/ui/InlineFeedback.vue' import QueryFeedback from '@/components/ui/QueryFeedback.vue' import StatusBadge from '@/components/ui/StatusBadge.vue' import SurfaceCard from '@/components/ui/SurfaceCard.vue' +import { formatEstimatedUSD } from '@/features/usage/estimated-cost' const filters = { range: '24h' } as const const client = useApiClient() @@ -24,8 +25,8 @@ const report = computed(() => usageQuery.data.value) const hasPipelineWarning = computed(() => Boolean( report.value && - (report.value.request_log.dropped_total > 0 || - report.value.request_log.write_failure_total > 0), + (report.value.collection_health.dropped_total > 0 || + report.value.collection_health.write_failure_total > 0), ), ) const observedAt = computed(() => { @@ -56,22 +57,11 @@ function formatReportedTokens(aggregate: UsageAggregateDto): string { function formatEstimatedCost(aggregate: UsageAggregateDto): string { if (aggregate.unpriced_request_count > 0) { - if (aggregate.estimated_cost_usd === 0) return t('home.usage.cost.unknown') return t('home.usage.cost.knownPlusUnknown', { - cost: new Intl.NumberFormat(locale.value, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 6, - maximumFractionDigits: 6, - }).format(aggregate.estimated_cost_usd), + cost: formatEstimatedUSD(aggregate.estimated_cost_usd, locale.value), }) } - return new Intl.NumberFormat(locale.value, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 6, - maximumFractionDigits: 6, - }).format(aggregate.estimated_cost_usd) + return formatEstimatedUSD(aggregate.estimated_cost_usd, locale.value) } @@ -175,8 +165,8 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string {