diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e72155..b875dbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: permissions: contents: read +env: + CI_PACKAGE_VERSION: 0.3.0-alpha.1 + jobs: dotnet: name: .NET (${{ matrix.os }}) @@ -24,6 +27,9 @@ jobs: DOTNET_INSTALL_DIR: ${{ runner.temp }}/.dotnet with: global-json-file: global.json + - name: Test release scripts + shell: pwsh + run: ./tools/Test-ReleaseScripts.ps1 -Version $env:CI_PACKAGE_VERSION - name: Restore locked dependencies run: dotnet restore OpenGameAgent.sln --locked-mode - name: Build @@ -35,7 +41,15 @@ jobs: - name: Pack libraries if: runner.os == 'Linux' shell: pwsh - run: ./tools/Pack-NuGet.ps1 + run: ./tools/Pack-NuGet.ps1 -PackageVersion $env:CI_PACKAGE_VERSION + - name: Verify packed asset payloads + if: runner.os == 'Linux' + shell: pwsh + run: ./tools/Test-FrozenReleaseAssets.ps1 -CandidateDirectory artifacts/nuget -FrozenDirectory artifacts/nuget + - name: Verify clean NuGet consumer + if: runner.os == 'Linux' + shell: pwsh + run: ./tools/Test-NuGetPackages.ps1 -PackageVersion $env:CI_PACKAGE_VERSION -ExpectedRepositoryCommit $env:GITHUB_SHA - name: Validate public tree shell: pwsh run: ./tools/Test-PublicTree.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd99b43..cfc55ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: required: true default: 0.3.0-alpha.1 publish: - description: Publish NuGet packages and the GitHub pre-release + description: Publish NuGet packages and the GitHub release required: true default: false type: boolean @@ -16,6 +16,10 @@ on: permissions: contents: read +concurrency: + group: release-${{ inputs.version }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -23,6 +27,8 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 env: DOTNET_INSTALL_DIR: ${{ runner.temp }}/.dotnet @@ -30,15 +36,22 @@ jobs: global-json-file: global.json - name: Validate release version shell: pwsh + env: + PUBLISH_REQUESTED: ${{ inputs.publish }} + SOURCE_REF: ${{ github.ref }} run: | - if ($env:RELEASE_VERSION -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') { - throw 'The release version is not valid SemVer.' + ./tools/Test-ReleaseScripts.ps1 -Version $env:RELEASE_VERSION + if ($env:PUBLISH_REQUESTED -eq 'true' -and $env:SOURCE_REF -ne 'refs/heads/main') { + throw 'Published releases must run from refs/heads/main.' } - run: dotnet restore OpenGameAgent.sln --locked-mode - run: dotnet build OpenGameAgent.sln -c Release --no-restore "-p:Version=${RELEASE_VERSION}" - run: dotnet test OpenGameAgent.sln -c Release --no-build --no-restore "-p:Version=${RELEASE_VERSION}" - shell: pwsh run: ./tools/Pack-NuGet.ps1 -PackageVersion $env:RELEASE_VERSION + - name: Verify clean NuGet consumer + shell: pwsh + run: ./tools/Test-NuGetPackages.ps1 -PackageVersion $env:RELEASE_VERSION -ExpectedRepositoryCommit $env:GITHUB_SHA - run: dotnet publish src/OpenGameAgent.Server/OpenGameAgent.Server.csproj -c Release --no-build --no-restore "-p:Version=${RELEASE_VERSION}" -o artifacts/server - name: Build Unity package shell: pwsh @@ -72,7 +85,7 @@ jobs: if-no-files-found: error stage-github-release: - if: ${{ inputs.publish }} + if: ${{ inputs.publish && github.ref == 'refs/heads/main' }} needs: build runs-on: ubuntu-latest permissions: @@ -82,60 +95,125 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} SOURCE_COMMIT: ${{ github.sha }} steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: OpenGameAgent-release-${{ inputs.version }} path: release-assets - - name: Stage immutable GitHub pre-release + - name: Stage immutable GitHub release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail tag="v${RELEASE_VERSION}" - - existing_release="" - if resolved_release="$(gh release view "${tag}" --json isDraft --jq .isDraft 2>/dev/null)"; then - existing_release="${resolved_release}" + package_count="$(jq -r '.packages | length' tools/release-packages.json)" + expected_asset_count="$((package_count + 4))" + prerelease_args=() + expected_prerelease=false + if [[ "${RELEASE_VERSION}" == *-* ]]; then + prerelease_args+=(--prerelease) + expected_prerelease=true fi - if [[ "${existing_release}" == "false" ]]; then - echo "::error::Release ${tag} is already public and cannot be replaced." + + mapfile -t assets < <( + find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print | + sort + ) + if [[ "${#assets[@]}" -ne "${expected_asset_count}" ]]; then + echo "::error::Expected ${expected_asset_count} downloadable assets, found ${#assets[@]}." + printf '%s\n' "${assets[@]}" exit 1 fi - if [[ "${existing_release}" == "true" ]]; then - gh release delete "${tag}" --yes - fi - tag_commit="" - if resolved_commit="$(gh api "repos/${GH_REPO}/commits/${tag}" --jq .sha 2>/dev/null)"; then - tag_commit="${resolved_commit}" - fi + api_url="${GITHUB_API_URL:-https://api.github.com}" + api_response="$(mktemp)" + frozen_assets="$(mktemp -d)" + trap 'rm -f "${api_response}"; rm -rf "${frozen_assets}"' EXIT + tag_status="$(curl --silent --show-error --retry 2 --retry-all-errors \ + --output "${api_response}" \ + --write-out '%{http_code}' \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${api_url}/repos/${GH_REPO}/git/ref/tags/${tag}")" release_target=(--target "${SOURCE_COMMIT}") - if [[ -n "${tag_commit}" ]]; then + if [[ "${tag_status}" == "200" ]]; then + tag_commit="$(gh api "repos/${GH_REPO}/commits/${tag}" --jq .sha)" if [[ "${tag_commit}" != "${SOURCE_COMMIT}" ]]; then echo "::error::Tag ${tag} points to ${tag_commit}, expected ${SOURCE_COMMIT}." exit 1 fi release_target=(--verify-tag) + elif [[ "${tag_status}" != "404" ]]; then + echo "::error::GitHub tag lookup returned HTTP ${tag_status}." + exit 1 fi - mapfile -t assets < <( - find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print | - sort - ) - if [[ "${#assets[@]}" -ne 13 ]]; then - echo "::error::Expected 13 downloadable assets, found ${#assets[@]}." - printf '%s\n' "${assets[@]}" + release_status="$(curl --silent --show-error --retry 2 --retry-all-errors \ + --output "${api_response}" \ + --write-out '%{http_code}' \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${api_url}/repos/${GH_REPO}/releases/tags/${tag}")" + existing_draft=false + if [[ "${release_status}" == "200" ]]; then + existing_draft="$(jq -r '.draft' "${api_response}")" + if [[ "${existing_draft}" != "true" ]]; then + echo "::error::Release ${tag} is already public and cannot be replaced." + exit 1 + fi + if [[ "${tag_status}" == "404" ]]; then + draft_target="$(jq -r '.target_commitish' "${api_response}")" + if [[ "${draft_target}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::Draft release target ${draft_target} does not match ${SOURCE_COMMIT}." + exit 1 + fi + fi + elif [[ "${release_status}" != "404" ]]; then + echo "::error::GitHub release lookup returned HTTP ${release_status}." exit 1 fi - gh release create "${tag}" \ - "${release_target[@]}" \ - --draft \ - --prerelease \ - --title "OpenGameAgent ${tag}" \ - --notes-file release-assets/RELEASE_NOTES.md \ - "${assets[@]}" + if [[ "${existing_draft}" == "true" ]]; then + remote_asset_count="$(jq -r '.assets | length' "${api_response}")" + if [[ "${remote_asset_count}" -eq 0 ]]; then + gh release upload "${tag}" "${assets[@]}" + else + remote_asset_names="$(jq -r '.assets[].name' "${api_response}" | sort)" + expected_asset_names="$(printf '%s\n' "${assets[@]##*/}" | sort)" + if [[ "${remote_asset_names}" != "${expected_asset_names}" ]]; then + echo "::error::Existing draft has a partial or unexpected frozen asset set." + diff -u \ + <(printf '%s\n' "${expected_asset_names}") \ + <(printf '%s\n' "${remote_asset_names}") || true + exit 1 + fi + gh release download "${tag}" --dir "${frozen_assets}" + ( + cd "${frozen_assets}" + sha256sum --check SHA256SUMS.txt + ) + fi + + gh release edit "${tag}" \ + "${release_target[@]}" \ + --draft \ + --prerelease="${expected_prerelease}" \ + --title "OpenGameAgent ${tag}" \ + --notes-file release-assets/RELEASE_NOTES.md + else + gh release create "${tag}" \ + "${release_target[@]}" \ + --draft \ + "${prerelease_args[@]}" \ + --title "OpenGameAgent ${tag}" \ + --notes-file release-assets/RELEASE_NOTES.md \ + "${assets[@]}" + fi expected="$(printf '%s\n' "${assets[@]##*/}" | sort)" actual="$(gh release view "${tag}" --json assets --jq '.assets[].name' | sort)" @@ -145,8 +223,26 @@ jobs: exit 1 fi + mkdir selected-release-assets + gh release download "${tag}" --dir selected-release-assets + ( + cd selected-release-assets + sha256sum --check SHA256SUMS.txt + ) + - name: Verify frozen asset provenance + shell: pwsh + run: ./tools/Test-FrozenReleaseAssets.ps1 -CandidateDirectory release-assets -FrozenDirectory selected-release-assets + - name: Verify frozen package manifests + shell: pwsh + run: ./tools/Test-NuGetPackages.ps1 -PackageVersion $env:RELEASE_VERSION -PackagesDirectory selected-release-assets -ExpectedRepositoryCommit $env:SOURCE_COMMIT -SkipConsumerRestore + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OpenGameAgent-frozen-assets-${{ inputs.version }} + path: selected-release-assets + if-no-files-found: error + publish-nuget: - if: ${{ inputs.publish }} + if: ${{ inputs.publish && github.ref == 'refs/heads/main' }} needs: stage-github-release runs-on: ubuntu-latest environment: release @@ -154,9 +250,12 @@ jobs: contents: read id-token: write env: + GH_REPO: ${{ github.repository }} RELEASE_VERSION: ${{ inputs.version }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 env: DOTNET_INSTALL_DIR: ${{ runner.temp }}/.dotnet @@ -164,33 +263,37 @@ jobs: global-json-file: global.json - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: OpenGameAgent-release-${{ inputs.version }} + name: OpenGameAgent-frozen-assets-${{ inputs.version }} path: release-assets + - name: Verify frozen asset checksums + shell: bash + run: | + set -euo pipefail + ( + cd release-assets + sha256sum --check SHA256SUMS.txt + ) + - name: Verify frozen NuGet packages + shell: pwsh + run: ./tools/Test-NuGetPackages.ps1 -PackageVersion $env:RELEASE_VERSION -PackagesDirectory release-assets -ExpectedRepositoryCommit $env:GITHUB_SHA - name: Exchange GitHub OIDC token for a temporary NuGet key id: nuget-login uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: user: ${{ secrets.NUGET_USER }} - name: Publish NuGet packages - shell: bash + shell: pwsh env: NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} - run: | - set -euo pipefail - mapfile -t packages < <(find release-assets -maxdepth 1 -type f -name '*.nupkg' -print | sort) - if [[ "${#packages[@]}" -ne 9 ]]; then - echo "::error::Expected 9 NuGet packages, found ${#packages[@]}." - exit 1 - fi - for package in "${packages[@]}"; do - dotnet nuget push "${package}" \ - --api-key "${NUGET_API_KEY}" \ - --source https://api.nuget.org/v3/index.json \ - --skip-duplicate - done + run: ./tools/Publish-NuGet.ps1 -Version $env:RELEASE_VERSION -ApiKey $env:NUGET_API_KEY -PackagesDirectory release-assets + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OpenGameAgent-published-assets-${{ inputs.version }} + path: release-assets + if-no-files-found: error publish-github-release: - if: ${{ inputs.publish }} + if: ${{ inputs.publish && github.ref == 'refs/heads/main' }} needs: publish-nuget runs-on: ubuntu-latest permissions: @@ -198,15 +301,103 @@ jobs: env: GH_REPO: ${{ github.repository }} RELEASE_VERSION: ${{ inputs.version }} + SOURCE_COMMIT: ${{ github.sha }} steps: - - name: Publish GitHub pre-release + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: OpenGameAgent-published-assets-${{ inputs.version }} + path: release-assets + - name: Publish GitHub release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail tag="v${RELEASE_VERSION}" - gh release edit "${tag}" --draft=false --prerelease - gh release view "${tag}" \ + expected_prerelease=false + if [[ "${RELEASE_VERSION}" == *-* ]]; then + expected_prerelease=true + fi + + ( + cd release-assets + sha256sum --check SHA256SUMS.txt + ) + + api_url="${GITHUB_API_URL:-https://api.github.com}" + api_response="$(mktemp)" + remote_assets="$(mktemp -d)" + trap 'rm -f "${api_response}"; rm -rf "${remote_assets}"' EXIT + + verify_tag_target() { + local allow_missing="$1" + local tag_status + tag_status="$(curl --silent --show-error --retry 2 --retry-all-errors \ + --output "${api_response}" \ + --write-out '%{http_code}' \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${api_url}/repos/${GH_REPO}/git/ref/tags/${tag}")" + if [[ "${tag_status}" == "200" ]]; then + local tag_commit + tag_commit="$(gh api "repos/${GH_REPO}/commits/${tag}" --jq .sha)" + if [[ "${tag_commit}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::Tag ${tag} points to ${tag_commit}, expected ${SOURCE_COMMIT}." + exit 1 + fi + elif [[ "${tag_status}" == "404" && "${allow_missing}" == "true" ]]; then + local draft_target + draft_target="$(gh release view "${tag}" --json targetCommitish --jq .targetCommitish)" + if [[ "${draft_target}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::Draft release target ${draft_target} does not match ${SOURCE_COMMIT}." + exit 1 + fi + else + echo "::error::GitHub tag lookup returned HTTP ${tag_status}." + exit 1 + fi + } + + verify_tag_target true + draft_state="$(gh release view "${tag}" --json isDraft --jq .isDraft)" + if [[ "${draft_state}" != "true" ]]; then + echo "::error::Release ${tag} is not the expected draft." + exit 1 + fi + expected_notes="$(cat release-assets/RELEASE_NOTES.md)" + actual_notes="$(gh release view "${tag}" --json body --jq .body)" + if [[ "${actual_notes}" != "${expected_notes}" ]]; then + echo "::error::Draft release notes changed before publication." + exit 1 + fi + + gh release download "${tag}" --dir "${remote_assets}" + mapfile -t local_assets < <( + find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print | sort + ) + mapfile -t downloaded_assets < <( + find "${remote_assets}" -maxdepth 1 -type f -print | sort + ) + if [[ "${#downloaded_assets[@]}" -ne "${#local_assets[@]}" ]]; then + echo "::error::Draft release asset count changed before publication." + exit 1 + fi + for local_asset in "${local_assets[@]}"; do + remote_asset="${remote_assets}/${local_asset##*/}" + if [[ ! -f "${remote_asset}" ]] || ! cmp --silent "${local_asset}" "${remote_asset}"; then + echo "::error::Draft release asset '${local_asset##*/}' differs from the build artifact." + exit 1 + fi + done + + gh release edit "${tag}" --draft=false --prerelease="${expected_prerelease}" + verify_tag_target false + actual_state="$(gh release view "${tag}" \ --json isDraft,isPrerelease \ - --jq 'select(.isDraft == false and .isPrerelease == true)' >/dev/null + --jq '[.isDraft, .isPrerelease] | @tsv')" + expected_state="$(printf 'false\t%s' "${expected_prerelease}")" + if [[ "${actual_state}" != "${expected_state}" ]]; then + echo "::error::Published release state '${actual_state}' does not match '${expected_state}'." + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 8860bc5..b83e285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ - Add durable action intents and receipts, prepared/dispatched/final recovery, resumable sequential and dependency-graph workflows, game-time memory and expiry, recursive skills, recurring schedules, actor mailboxes, context-window admission, large-result artifact spill, and media-generation API contracts. - Add crash-tolerant, cross-process-coordinated local file stores for sessions, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, and hot-reloaded directory skills, with identity and saved-state trust checks. - Add capability-aware provider/model catalogs, reasoning and cost metadata, dynamic refresh, replaceable authentication, and developer-hosted short-lived credentials. +- Add an executable bundled model directory with provider-specific dispatch, compatibility flags, request transforms, cost tiers, response observation, and nine native wire APIs. +- Add optional bounded browser/device authentication flows, explicit client registration, stored credential refresh, and cancellation-safe login settlement. - Add lazy external tool-server search/describe/call by default with explicit direct exposure for small trusted catalogs. -- Add strict streaming OpenAI-compatible and generic HTTP media providers, bounded request/response parsing, rotating credentials, polling controls, and retry/fallback composition that stops before replaying meaningful streamed output. +- Add native Anthropic, Bedrock, Google Gemini/Vertex, Mistral, OpenAI Responses/Azure, OpenAI-compatible, remote-proxy, and message-gateway providers with cross-provider transcript handoff. +- Add a provider-neutral image/audio/video registry, strict generic HTTP media jobs, dedicated image generation with progressive previews, and typed partial tool output. +- Add bounded request/response parsing, rotating credentials, safe response metadata observation, protocol-aware retries, and retry/fallback composition that stops before replaying meaningful streamed output. +- Add append-only branch/lane session history, bounded search and projection, usage accounting, cross-process mutation safety, prompt templates, richer skill diagnostics, and context-overflow recovery. - Add Godot 4.7 .NET and Unity 6 adapters with local and remote modes, bounded main-thread delivery with terminal reservation, package verification, and real local-runtime editor tests on Windows. - Add an optional .NET 8 JSON/SSE server, engine-compatible client, authenticated steering and abort, bounded JSON request bodies, strict wire contracts, and redirect/credential guidance. - Add bilingual documentation, a buildable living-world action example, pinned release automation, and cross-platform .NET validation. diff --git a/OpenGameAgent.sln b/OpenGameAgent.sln index 5627ea5..40d3fa3 100644 --- a/OpenGameAgent.sln +++ b/OpenGameAgent.sln @@ -46,6 +46,54 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.Tests", "tests\OpenGameAgent.Models.Tests\OpenGameAgent.Models.Tests.csproj", "{839EA4C2-45A0-4E78-8FAE-E39155C96F4C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.OpenAI", "src\OpenGameAgent.Providers.OpenAI\OpenGameAgent.Providers.OpenAI.csproj", "{AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.OpenAI.Tests", "tests\OpenGameAgent.Providers.OpenAI.Tests\OpenGameAgent.Providers.OpenAI.Tests.csproj", "{AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Anthropic", "src\OpenGameAgent.Providers.Anthropic\OpenGameAgent.Providers.Anthropic.csproj", "{AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Anthropic.Tests", "tests\OpenGameAgent.Providers.Anthropic.Tests\OpenGameAgent.Providers.Anthropic.Tests.csproj", "{AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Google", "src\OpenGameAgent.Providers.Google\OpenGameAgent.Providers.Google.csproj", "{AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Google.Tests", "tests\OpenGameAgent.Providers.Google.Tests\OpenGameAgent.Providers.Google.Tests.csproj", "{AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Mistral", "src\OpenGameAgent.Providers.Mistral\OpenGameAgent.Providers.Mistral.csproj", "{AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Mistral.Tests", "tests\OpenGameAgent.Providers.Mistral.Tests\OpenGameAgent.Providers.Mistral.Tests.csproj", "{AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Bedrock", "src\OpenGameAgent.Providers.Bedrock\OpenGameAgent.Providers.Bedrock.csproj", "{AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Bedrock.Tests", "tests\OpenGameAgent.Providers.Bedrock.Tests\OpenGameAgent.Providers.Bedrock.Tests.csproj", "{AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Remote", "src\OpenGameAgent.Providers.Remote\OpenGameAgent.Providers.Remote.csproj", "{AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Remote.Tests", "tests\OpenGameAgent.Providers.Remote.Tests\OpenGameAgent.Providers.Remote.Tests.csproj", "{AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.BuiltIn", "src\OpenGameAgent.Models.BuiltIn\OpenGameAgent.Models.BuiltIn.csproj", "{AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.BuiltIn.Tests", "tests\OpenGameAgent.Models.BuiltIn.Tests\OpenGameAgent.Models.BuiltIn.Tests.csproj", "{AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.ProviderTransport", "src\OpenGameAgent.ProviderTransport\OpenGameAgent.ProviderTransport.csproj", "{6267ADBA-AC85-4357-8F55-F4523DBF7472}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.ProviderTransport.Tests", "tests\OpenGameAgent.ProviderTransport.Tests\OpenGameAgent.ProviderTransport.Tests.csproj", "{71A5BDBE-AFDF-424F-AA56-6DB3A90657DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Media", "src\OpenGameAgent.Media\OpenGameAgent.Media.csproj", "{020649CD-50C7-48ED-9EB9-A2CC00A42162}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Media.Tests", "tests\OpenGameAgent.Media.Tests\OpenGameAgent.Media.Tests.csproj", "{931186B1-097D-467F-9AC7-AA55C4680B49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.Auth.BuiltIn", "src\OpenGameAgent.Models.Auth.BuiltIn\OpenGameAgent.Models.Auth.BuiltIn.csproj", "{6E747055-19EE-46F4-ADCA-FC84A5606008}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.Auth.BuiltIn.Tests", "tests\OpenGameAgent.Models.Auth.BuiltIn.Tests\OpenGameAgent.Models.Auth.BuiltIn.Tests.csproj", "{9A687BE1-F8B4-4B89-9991-464914D69E28}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.OpenRouter", "src\OpenGameAgent.Providers.OpenRouter\OpenGameAgent.Providers.OpenRouter.csproj", "{3C22F458-B759-4DAC-B367-AD876679A2FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.OpenRouter.Tests", "tests\OpenGameAgent.Providers.OpenRouter.Tests\OpenGameAgent.Providers.OpenRouter.Tests.csproj", "{11929951-6AC8-445D-9539-F859E583EEC2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.MessageGateway", "src\OpenGameAgent.Providers.MessageGateway\OpenGameAgent.Providers.MessageGateway.csproj", "{31B59D2C-2431-47CB-B3A1-6A2BE4C20055}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.MessageGateway.Tests", "tests\OpenGameAgent.Providers.MessageGateway.Tests\OpenGameAgent.Providers.MessageGateway.Tests.csproj", "{9CFA2749-BE81-45DE-A07B-CC005F87C5BD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -132,11 +180,131 @@ Global {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913}.Release|Any CPU.Build.0 = Release|Any CPU + {AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914}.Release|Any CPU.Build.0 = Release|Any CPU + {6267ADBA-AC85-4357-8F55-F4523DBF7472}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6267ADBA-AC85-4357-8F55-F4523DBF7472}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6267ADBA-AC85-4357-8F55-F4523DBF7472}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6267ADBA-AC85-4357-8F55-F4523DBF7472}.Release|Any CPU.Build.0 = Release|Any CPU + {71A5BDBE-AFDF-424F-AA56-6DB3A90657DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71A5BDBE-AFDF-424F-AA56-6DB3A90657DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71A5BDBE-AFDF-424F-AA56-6DB3A90657DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71A5BDBE-AFDF-424F-AA56-6DB3A90657DC}.Release|Any CPU.Build.0 = Release|Any CPU + {020649CD-50C7-48ED-9EB9-A2CC00A42162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {020649CD-50C7-48ED-9EB9-A2CC00A42162}.Debug|Any CPU.Build.0 = Debug|Any CPU + {020649CD-50C7-48ED-9EB9-A2CC00A42162}.Release|Any CPU.ActiveCfg = Release|Any CPU + {020649CD-50C7-48ED-9EB9-A2CC00A42162}.Release|Any CPU.Build.0 = Release|Any CPU + {931186B1-097D-467F-9AC7-AA55C4680B49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {931186B1-097D-467F-9AC7-AA55C4680B49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {931186B1-097D-467F-9AC7-AA55C4680B49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {931186B1-097D-467F-9AC7-AA55C4680B49}.Release|Any CPU.Build.0 = Release|Any CPU + {6E747055-19EE-46F4-ADCA-FC84A5606008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E747055-19EE-46F4-ADCA-FC84A5606008}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E747055-19EE-46F4-ADCA-FC84A5606008}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E747055-19EE-46F4-ADCA-FC84A5606008}.Release|Any CPU.Build.0 = Release|Any CPU + {9A687BE1-F8B4-4B89-9991-464914D69E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A687BE1-F8B4-4B89-9991-464914D69E28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A687BE1-F8B4-4B89-9991-464914D69E28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A687BE1-F8B4-4B89-9991-464914D69E28}.Release|Any CPU.Build.0 = Release|Any CPU + {3C22F458-B759-4DAC-B367-AD876679A2FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C22F458-B759-4DAC-B367-AD876679A2FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C22F458-B759-4DAC-B367-AD876679A2FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C22F458-B759-4DAC-B367-AD876679A2FC}.Release|Any CPU.Build.0 = Release|Any CPU + {11929951-6AC8-445D-9539-F859E583EEC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11929951-6AC8-445D-9539-F859E583EEC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11929951-6AC8-445D-9539-F859E583EEC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11929951-6AC8-445D-9539-F859E583EEC2}.Release|Any CPU.Build.0 = Release|Any CPU + {31B59D2C-2431-47CB-B3A1-6A2BE4C20055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31B59D2C-2431-47CB-B3A1-6A2BE4C20055}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31B59D2C-2431-47CB-B3A1-6A2BE4C20055}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31B59D2C-2431-47CB-B3A1-6A2BE4C20055}.Release|Any CPU.Build.0 = Release|Any CPU + {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {01759D73-7B80-47A2-9D7D-154CC64C6851} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} {98A0255B-E6C3-46C4-868C-A16FB559A79C} = {86AE6217-BFEE-4349-945A-70ECEC211437} {CC89911F-B920-4203-8CE4-03D434C3B01E} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} {839EA4C2-45A0-4E78-8FAE-E39155C96F4C} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0A1-6DF6-4B39-9B3C-CF20BA2C0901} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0A2-6DF6-4B39-9B3C-CF20BA2C0902} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0A3-6DF6-4B39-9B3C-CF20BA2C0903} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0A4-6DF6-4B39-9B3C-CF20BA2C0904} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0A5-6DF6-4B39-9B3C-CF20BA2C0905} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0A6-6DF6-4B39-9B3C-CF20BA2C0906} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0A7-6DF6-4B39-9B3C-CF20BA2C0907} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0A8-6DF6-4B39-9B3C-CF20BA2C0908} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0A9-6DF6-4B39-9B3C-CF20BA2C0909} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0AA-6DF6-4B39-9B3C-CF20BA2C0910} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0AB-6DF6-4B39-9B3C-CF20BA2C0911} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0AC-6DF6-4B39-9B3C-CF20BA2C0912} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {AA19D0AD-6DF6-4B39-9B3C-CF20BA2C0913} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {AA19D0AE-6DF6-4B39-9B3C-CF20BA2C0914} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {6267ADBA-AC85-4357-8F55-F4523DBF7472} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {71A5BDBE-AFDF-424F-AA56-6DB3A90657DC} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {020649CD-50C7-48ED-9EB9-A2CC00A42162} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {931186B1-097D-467F-9AC7-AA55C4680B49} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {6E747055-19EE-46F4-ADCA-FC84A5606008} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {9A687BE1-F8B4-4B89-9991-464914D69E28} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {3C22F458-B759-4DAC-B367-AD876679A2FC} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {11929951-6AC8-445D-9539-F859E583EEC2} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {31B59D2C-2431-47CB-B3A1-6A2BE4C20055} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {9CFA2749-BE81-45DE-A07B-CC005F87C5BD} = {86AE6217-BFEE-4349-945A-70ECEC211437} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0556974..29d5797 100644 --- a/README.md +++ b/README.md @@ -76,21 +76,30 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari | Area | Capability | | --- | --- | -| Agent kernel | Streaming typed messages, tool loop, progress events, steering, follow-up, hooks, cancellation, strict transcript validation, provider failures as results | +| Agent kernel | Streaming typed messages, tool loop, typed partial tool results, steering, follow-up, hooks, cancellation, strict transcript validation, provider failures as results | | Tool execution | Bounded JSON Schema subset, guaranteed result for every accepted call, safe parallel reads, conflict-key serialization, policy blocking/termination, timeouts, uncertain write outcomes | | Game runtime | Arbitrary JSON input, game clocks/timelines, fast/full/workflow routing, optimistic sessions, duplicate-input protection, actor concurrency, active-run steering/abort | | Extension API | Immutable builder; prompt/context/tool/skill/route/workflow/hook/provider/service registration; typed lifecycle events and channels; namespaced persistent state | | Official extensions | Tool policy and search, structured player questions/recommended replies, goals, memory, artifacts, knowledge, delegation, tracing, and durable parallel workflow graphs | | World primitives | Durable actions, resumable workflows, memories, skills, signals, game-time schedules, actor mailboxes | -| Models and auth | Capability/context/reasoning/cost catalog, dynamic model refresh, static/environment/stored/local auth, developer-hosted short-lived credential gateway | +| Models and auth | Bundled capability/context/reasoning/cost directory, dynamic refresh, API-key/environment/stored/OAuth/local auth, developer-hosted short-lived credential gateway | | External tools | Lazy on-demand search/describe/call by default; explicit direct exposure for small trusted catalogs | -| Providers | Streaming OpenAI-compatible text/tool API; generic HTTP image/audio/video API; retry and fallback decorators | -| Persistence | Crash-tolerant, cross-process-coordinated local files for sessions, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, and recursive hot-reloaded skills | +| Providers | Native Anthropic, Amazon Bedrock, Google Gemini/Vertex, Mistral, OpenAI Responses/Azure, OpenAI-compatible, remote gateway, and message-gateway transports; retry/fallback decorators | +| Generated media | Provider-neutral image/audio/video registry, generic async HTTP jobs, and a dedicated OpenRouter image adapter with progressive previews | +| Persistence | Crash-tolerant local snapshots plus optional append-only session history, cross-process coordination, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, skills, and prompt templates | | Placement | Shared `netstandard2.1` runtime in Godot, Unity, or another C# host; optional .NET 8 HTTP/SSE service and engine client | | Engines | Godot 4.7 .NET and Unity 6 packages, both exercised in real Windows editors | Run inputs, model content, tool catalogs, loops, queues, progress, and concurrency are bounded by explicit limits. Context admission runs before every model request, model and tool calls have deadlines, and large tool results can be retained as artifacts instead of repeatedly filling the prompt. Game-owned stores and rankers can replace the included in-memory or local-file implementations. +### Model access without hand-wiring every provider + +`OpenGameAgent.Models.BuiltIn` turns the bundled model directory into an executable runtime. It currently dispatches nine wire APIs across 27 provider definitions and hundreds of text/tool-capable models, applying provider-specific request formats, reasoning settings, compatibility flags, cost metadata, authentication, cancellation, and bounded response handling. The lower provider packages remain independently usable when a game wants an explicit model and endpoint instead of a directory. + +`OpenGameAgent.Models.Auth.BuiltIn` adds opt-in browser or device authorization flows for supported subscription providers. Public client registrations are never embedded in the framework: flows that require a client ID remain disabled until the game developer supplies one. `OpenGameAgent.ProviderTransport` exposes only allowlisted, bounded response metadata to observers and never passes credentials or arbitrary response headers to tracing code. + +Image, audio, and video generation use a separate model registry because generation jobs, previews, polling, and outputs are not chat completions. The framework ships the neutral registry, a generic HTTP job adapter, and a dedicated image provider; games can register local generators or additional APIs without changing the agent kernel. + ## Minimal kernel ```csharp diff --git a/README.zh-CN.md b/README.zh-CN.md index 2b56a2e..fcaf029 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -74,21 +74,30 @@ GameAgentRuntime | 模块 | 能力 | | --- | --- | -| Agent 内核 | 流式类型化消息、工具循环、进度事件、steering、follow-up、hooks、取消、严格会话校验、提供方错误结果化 | +| Agent 内核 | 流式类型化消息、工具循环、类型化工具中间结果、steering、follow-up、hooks、取消、严格会话校验、提供方错误结果化 | | 工具执行 | 有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 | | 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort | | 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 | | 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 | | 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、角色邮箱 | -| 模型与认证 | 模型能力/上下文/推理级别/成本目录、动态刷新、静态/环境/存储/本地认证、开发者托管短期凭证网关 | +| 模型与认证 | 内置模型能力/上下文/推理级别/成本目录、动态刷新、API Key/环境/存储/OAuth/本地认证、开发者托管短期凭证网关 | | 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 | -| 提供方 | OpenAI-compatible 流式文本/工具 API;通用 HTTP 图片/语音/视频 API;重试与回退包装器 | -| 持久化 | 会话、动作日志、Workflow 检查点、记忆、邮箱、产物、委派,以及递归热更新 Skills 的崩溃安全、跨进程协调本地文件实现 | +| 提供方 | Anthropic、Amazon Bedrock、Google Gemini/Vertex、Mistral、OpenAI Responses/Azure、OpenAI-compatible、远程网关和消息网关;重试与回退包装器 | +| 生成式媒体 | 图片/语音/视频中立注册表、通用异步 HTTP 任务,以及带渐进预览的专用图片适配器 | +| 持久化 | 崩溃安全本地快照、可选追加式会话历史、跨进程协调、动作日志、Workflow 检查点、记忆、邮箱、产物、委派、Skills 与提示词模板 | | 运行位置 | `netstandard2.1` 共享运行时可放在 Godot、Unity 或其他 C# 宿主;可选 .NET 8 HTTP/SSE 服务端与引擎客户端 | | 引擎 | Godot 4.7 .NET 与 Unity 6 包,均已在 Windows 真实编辑器中通过测试 | 运行输入、模型内容、工具目录、循环、队列、进度事件与并发都有明确上限。每次模型调用前都会执行上下文准入,模型与工具调用都有截止时间,大型工具结果可以保存为产物而不是反复占满提示词。游戏可以替换内置的内存或本地文件实现。 +### 无需手工拼接每个模型提供方 + +`OpenGameAgent.Models.BuiltIn` 会把内置模型目录变成可直接执行的运行时。目前它通过 9 种线路协议分发 27 个提供方定义与数百个可执行文本/工具模型,并统一应用提供方请求格式、推理参数、兼容性、成本、认证、取消与响应限界。开发者也可以绕过目录,直接使用底层 Provider 包连接一个明确的模型和端点。 + +`OpenGameAgent.Models.Auth.BuiltIn` 为支持的订阅服务提供可选浏览器或设备授权。框架不会内嵌公共客户端注册信息:需要 Client ID 的流程只有在游戏开发者显式提供后才会启用。`OpenGameAgent.ProviderTransport` 只向观察器暴露白名单内且有界的响应元数据,不会把凭证或任意响应头交给追踪代码。 + +图片、语音和视频生成使用独立的模型注册表,因为生成任务、渐进预览、轮询和输出并不是聊天补全。框架提供中立注册表、通用 HTTP 任务适配器和专用图片 Provider;本地生成器或其他 API 可以作为可选包注册,不需要修改 Agent 内核。 + ## 最小内核 ```csharp diff --git a/docs/architecture.md b/docs/architecture.md index 4496b7e..1915ec2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,9 @@ It does not own a universal world model. Context remains opaque JSON supplied by - `OpenGameAgent.Extensions` adds policy, searchable tools, structured player interaction, goals, memory, artifacts, external knowledge, delegation, tracing, and durable workflow graphs. - `OpenGameAgent.Models` adds provider/model catalogs, capability-aware selection, reasoning levels, cost metadata, dynamic refresh, and replaceable authentication. +- `OpenGameAgent.Models.BuiltIn` turns the bundled directory into an executable multi-provider model runtime; `OpenGameAgent.Models.Auth.BuiltIn` adds explicitly configured browser and device authorization flows. +- `OpenGameAgent.ProviderTransport` centralizes bounded response observations, header guards, and retry metadata without adding HTTP concepts to the kernel. +- `OpenGameAgent.Media` routes image, audio, and video generation by provider/model capability while keeping generation jobs outside the text/tool protocol. - `OpenGameAgent.Connectors.Mcp` exposes external tool servers through one lazy, searchable tool by default. Direct tool exposure is an explicit opt-in. - Provider, persistence, engine, client, and server packages stay replaceable and do not change kernel semantics. diff --git a/docs/features.md b/docs/features.md index bf975c9..3e60767 100644 --- a/docs/features.md +++ b/docs/features.md @@ -8,6 +8,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | --- | --- | | Stream dialogue or reasoning | `Agent.Subscribe`, `AgentEvent`, `ModelStreamEvent` | | Multi-step plan and action loop | `Agent`, `AgentTool` | +| Stream typed partial tool output or generated previews | `ToolExecutionContext.ReportProgressAsync`, `ToolProgress.Content` | | Interrupt or amend current work | `Agent.Steer`, `Agent.Abort` | | Queue the next interaction | `Agent.FollowUp` | | Change prompts or context per turn | `AgentHooks` | @@ -49,6 +50,8 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Run many NPCs concurrently | `GameRuntimeLimits.MaxConcurrentActors`, `MultiActorScheduler` | | Correct or cancel an active NPC run | `GameAgentRuntime.TrySteer`, `GameAgentRuntime.TryAbort` | | Persist transcripts and deduplicate inputs | `IGameSessionStore` | +| Keep an append-only branch/lane audit history | `IGameSessionHistoryRepository`, `GameSessionHistory` | +| Fork, search, page, or project a session history | `GameSessionHistory`, `GameHistoryContextProjection` | | Compact a long transcript | `IGameTranscriptCompactor` | ## World actions and simulation @@ -62,11 +65,13 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Apply custom semantic ranking | `IGameMemoryRanker`, `RankedGameMemoryStore` | | Add reusable behavior instructions | `IGameSkillSource`, `GameSkill` | | Load portable or game-filtered skills | `DirectoryGameSkillSource` (`SKILL.md` or `skill.json`) | +| Load reusable prompt templates with bounded arguments | `FileGamePromptTemplateLoader`, `GamePromptTemplate` | | Trigger and save monthly/daily/turn events | `GameTimeScheduler`, `CaptureState` | | Send work between persistent actors | `IGameMailbox` | | Resume fixed multi-stage logic | `DurableGameWorkflow` | | Run durable dependency graphs with bounded parallel branches | `DurableGameWorkflowGraph` | | Generate images/audio/video | `IGameMediaGenerator`, `GameMediaGenerationTool` | +| Route generation by provider/model and media capability | `GameMediaModelRegistry` | | Spill large tool output and retrieve it later | `ArtifactExtension`, `IGameAgentArtifactStore` | | Recall scoped memory through an extension | `GameMemoryExtension` | @@ -78,7 +83,12 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Register and select local or remote models | `GameModelCatalog` | | Refresh a provider's model list safely | `GameModelProviderRegistration.RefreshModels`, `GameModelCatalog.RefreshAsync` | | Resolve API keys, OAuth-style tokens, or local/no-auth modes | `IGameProviderAuthentication`, `IGameCredentialStore` | +| Load the bundled model directory as executable providers | `BuiltInGameModelRuntime` | +| Register supported browser/device authorization flows | `BuiltInGameOAuthRegistration` | +| Observe bounded provider response metadata | `ProviderResponseObserver` | | Fetch short-lived developer-hosted credentials | `DeveloperGatewayProvider`, `HttpDeveloperGatewayCredentialSource` | +| Run the same provider behind a trusted remote service | `RemoteModelProvider`, `ModelProviderProxyServer` | +| Connect to a compatible message-gateway service | `MessageGatewayProvider` | | Use external tool servers without loading every schema into context | `McpToolConnectorExtension` (default `OnDemand`) | | Expose every remote tool natively when the catalog is small | `GameMcpToolExposure.Direct` | @@ -87,13 +97,15 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. In-memory implementations are useful for tests and short-lived sessions. The `OpenGameAgent.Persistence` package includes local-file stores for: - game sessions; +- append-only session histories with branches, lanes, records, and usage statistics; - action journals; - workflow checkpoints; - memories; - mailboxes; - agent artifacts; - delegation records; -- directory-backed skills. +- directory-backed skills; +- directory-backed prompt templates. File stores coordinate writers that use the same directory through cross-process leases, but they are not a distributed database. A multiplayer or multi-host service should implement the same interfaces using transactional shared storage and explicit actor ownership. Completed action, workflow, mailbox, and deduplication records are intentionally retained to preserve replay safety; long-running products should implement retention or archival in their game-owned stores rather than deleting evidence blindly. diff --git a/docs/getting-started.md b/docs/getting-started.md index 67ae132..7d34bb1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,10 +5,10 @@ The fastest path is to run the buildable console example, then replace its conte ## Requirements - .NET SDK 8.0 -- an OpenAI-compatible chat-completions endpoint with streaming tool calls +- for the console example, an OpenAI-compatible chat-completions endpoint with streaming tool calls - a model that supports the behavior your game exposes -No model is installed by this repository. The endpoint may be a cloud service or a local server. +No model is installed by this repository. The endpoint may be a cloud service or a local server. Native provider packages and the bundled multi-provider directory are covered later in this guide. ## Run the example @@ -172,8 +172,25 @@ Use `ArtifactExtension` when tools can return large text or JSON. Results above `OpenGameAgent.Models` describes input/output capabilities, context and output limits, reasoning levels, availability, and cost separately from the core provider interface. A `GameModelCatalog` can combine static and dynamically refreshed local or remote providers and resolve a compatible model for a run. +Install `OpenGameAgent.Models.BuiltIn` when the game should use the bundled provider/model directory instead of constructing one low-level adapter itself. Configure a credential first; the example below reads `OPENAI_API_KEY` from the environment by default: + +```csharp +using OpenGameAgent.Models.BuiltIn; + +var modelRuntime = new BuiltInGameModelRuntime( + new BuiltInGameModelRuntimeOptions(httpClient)); +var available = await modelRuntime.Catalog.GetAvailableModelsAsync("openai"); +var selected = available.First(); +var provider = modelRuntime.CreateProvider("openai"); +var agent = new Agent(new AgentOptions(provider, selected.ModelId)); +``` + +Availability checks use the configured authentication chain, so a provider with no usable credentials is not presented as ready. A game may select by required input/output capability and reasoning level rather than taking the first result. Direct provider packages remain appropriate when a title intentionally supports only one endpoint. + Authentication is replaceable: static credentials, environment resolution, game-owned credential stores, or local/no-auth providers can share the same catalog. If the developer pays for inference, use `DeveloperGatewayProvider` to obtain short-lived scoped access from the developer's authenticated gateway. Never ship a permanent upstream provider key in a client build. +`OpenGameAgent.Models.Auth.BuiltIn` registers optional browser and device flows against the same credential store. Client IDs are developer configuration, not framework defaults; a flow that requires one stays disabled until it is explicitly supplied. Use an encrypted platform credential store in a shipped product—the included in-memory store is for composition and tests, not durable secret protection. + ## Steer or abort an active actor Long autonomous actions can receive urgent structured observations without starting a second run for the same actor: diff --git a/docs/media.md b/docs/media.md index c5d6116..f083cc5 100644 --- a/docs/media.md +++ b/docs/media.md @@ -7,10 +7,15 @@ OpenGameAgent defines provider-neutral image, audio, and video generation contra - `GameMediaGenerationRequest` carries a stable request ID, media kind, structured context, provider parameters, optional prompt, and source resource references. - `IGameMediaGenerator` performs generation and reports bounded progress. - `GameMediaGenerationResult` returns one or more `ResourceContent` references plus structured metadata. +- `GameMediaGenerationProgress` may carry a bounded preview resource. When generation is exposed as a tool, inline data is converted to typed image/audio/video progress content for the host UI. - `GameMediaGenerationTool` exposes a generator to the agent as a non-idempotent write by default; a stable request ID lets the media service deduplicate or resume submissions when it implements that guarantee. +`OpenGameAgent.Media` adds a provider/model registry on top of these contracts. It validates model capability, media kind, authentication, request/result limits, refresh races, cancellation, and timeouts, then returns an in-band completed/failed/canceled generation result. The registry retains the underlying generator's progress and async job behavior. + `OpenGameAgent.Providers.MediaHttp` implements a bounded JSON HTTP transport for cloud or local APIs that implement the documented request/job shape. If a service uses different fields, authentication, upload semantics, or durable job handles, adapt it behind `IGameMediaGenerator` instead of pretending the wire formats are interchangeable. The game is responsible for downloading or importing resources after validating origin, content type, size, checksum, license metadata, storage quota, and content policy. +`OpenGameAgent.Providers.OpenRouter` is a dedicated image-generation adapter with model discovery, text and image references, buffered or SSE results, progressive previews, usage metadata, and the same unified provider authentication used by the media registry. It is separate from the generic HTTP adapter because its wire contract is different. + Use `GetApiKeyAsync` when credentials rotate or expire during long-running jobs. Status URLs are restricted to the submission endpoint's origin by default. If cross-origin polling is enabled, authorization is still withheld from the other origin unless `SendAuthorizationToCrossOriginStatusUrls` is explicitly enabled. ## Recommended flow diff --git a/docs/nuget-package-readme.md b/docs/nuget-package-readme.md index bea1827..3af15bd 100644 --- a/docs/nuget-package-readme.md +++ b/docs/nuget-package-readme.md @@ -13,4 +13,4 @@ Open-source C# agent runtime for AI-native games, autonomous NPCs, and interacti Documentation and source: https://github.com/EricSun0218/OpenGameAgent -This package is an alpha release. See the repository README and getting-started guide before integrating it into a shipped game. +Before 1.0, public APIs may change between releases. See the repository README and getting-started guide before integrating it into a shipped game. diff --git a/examples/OpenGameAgent.Example/packages.lock.json b/examples/OpenGameAgent.Example/packages.lock.json index 5c8cb04..a497331 100644 --- a/examples/OpenGameAgent.Example/packages.lock.json +++ b/examples/OpenGameAgent.Example/packages.lock.json @@ -24,8 +24,12 @@ "type": "Project", "dependencies": { "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.providertransport": { + "type": "Project" } } } diff --git a/src/OpenGameAgent.Connectors.Mcp/packages.lock.json b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json index 0d84afc..356a34d 100644 --- a/src/OpenGameAgent.Connectors.Mcp/packages.lock.json +++ b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json @@ -153,7 +153,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -161,6 +162,12 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } } } } diff --git a/src/OpenGameAgent.Extensions/ArtifactExtension.cs b/src/OpenGameAgent.Extensions/ArtifactExtension.cs index bebd676..22ddcf6 100644 --- a/src/OpenGameAgent.Extensions/ArtifactExtension.cs +++ b/src/OpenGameAgent.Extensions/ArtifactExtension.cs @@ -212,8 +212,8 @@ public void Configure(GameAgentExtensionApi api) "large-tool-result-spill", context => new AgentHooks { - AfterToolCallAsync = (call, result, _, cancellationToken) => - SpillToolResultAsync(context, call, result, cancellationToken), + AfterToolCallAsync = (toolContext, cancellationToken) => + SpillToolResultAsync(context, toolContext.ToolCall, toolContext.Result, cancellationToken), }); } diff --git a/src/OpenGameAgent.Models/ModelCatalogExtension.cs b/src/OpenGameAgent.Extensions/GameModelCatalogExtension.cs similarity index 85% rename from src/OpenGameAgent.Models/ModelCatalogExtension.cs rename to src/OpenGameAgent.Extensions/GameModelCatalogExtension.cs index 1c48002..01b93d2 100644 --- a/src/OpenGameAgent.Models/ModelCatalogExtension.cs +++ b/src/OpenGameAgent.Extensions/GameModelCatalogExtension.cs @@ -1,8 +1,7 @@ -using System; -using System.Collections.Generic; using OpenGameAgent.Kernel; +using OpenGameAgent.Models; -namespace OpenGameAgent.Models; +namespace OpenGameAgent.Extensions; public sealed class GameModelCatalogExtension : IGameAgentExtension { @@ -12,7 +11,7 @@ public GameModelCatalogExtension(GameModelCatalog catalog, string extensionId = { _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); Descriptor = new GameAgentExtensionDescriptor( - GameModelDescriptor.RequireId(extensionId, nameof(extensionId)), + extensionId, "1.0.0", "Provider discovery, model capabilities, authentication state, and model selection.", new[] { "model-catalog", "provider-auth", "dynamic-models" }); @@ -32,7 +31,7 @@ public void Configure(GameAgentExtensionApi api) { api.RegisterModelProvider( provider.Descriptor.ProviderId, - _catalog.CreateDispatchProvider(provider.Descriptor.ProviderId)); + _catalog.CreateProvider(provider.Descriptor.ProviderId)); } } @@ -53,7 +52,7 @@ public GameModelSelection Select( return new GameModelSelection( resolution.Model.ModelId, parameters: resolution.CreateParameters(baseline), - provider: _catalog.CreateDispatchProvider(resolution.Model.ProviderId), + provider: _catalog.CreateProvider(resolution.Model.ProviderId), contextWindowTokens: resolution.Model.ContextWindowTokens, maximumOutputTokens: resolution.Model.MaximumOutputTokens); } diff --git a/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj index 20b8fd7..4ee7468 100644 --- a/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj +++ b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj @@ -6,5 +6,6 @@ + diff --git a/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs b/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs index 9c32307..3f739a3 100644 --- a/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs +++ b/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs @@ -195,9 +195,9 @@ public void Configure(GameAgentExtensionApi api) "tool-policy-gate", runContext => new AgentHooks { - BeforeToolCallAsync = async (call, agentContext, cancellationToken) => + BeforeToolCallAsync = async (toolContext, cancellationToken) => { - var current = call; + var current = toolContext.ToolCall; string? replacement = null; var applied = false; foreach (var policy in _policies) @@ -206,7 +206,7 @@ public void Configure(GameAgentExtensionApi api) try { decision = await policy.EvaluateAsync( - new GameToolPolicyContext(runContext.Input, current, agentContext), + new GameToolPolicyContext(runContext.Input, current, toolContext.Context), cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"Policy '{policy.Id}' returned null."); } diff --git a/src/OpenGameAgent.Extensions/packages.lock.json b/src/OpenGameAgent.Extensions/packages.lock.json index 2d58bb3..99754ad 100644 --- a/src/OpenGameAgent.Extensions/packages.lock.json +++ b/src/OpenGameAgent.Extensions/packages.lock.json @@ -75,6 +75,12 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } } } } diff --git a/src/OpenGameAgent.Kernel/Agent.cs b/src/OpenGameAgent.Kernel/Agent.cs index 4c442b1..f611a8e 100644 --- a/src/OpenGameAgent.Kernel/Agent.cs +++ b/src/OpenGameAgent.Kernel/Agent.cs @@ -19,7 +19,7 @@ public sealed class Agent private ToolExecutionMode _toolExecution; private readonly Func _clock; private readonly Func _runIdFactory; - private readonly string? _sessionId; + private string? _sessionId; private readonly PendingMessageQueue _steering; private readonly PendingMessageQueue _followUps; private readonly List _subscribers = new(); @@ -85,6 +85,7 @@ public AgentState State _systemPrompt, _provider, _model, + _sessionId, _parameters, _tools, _messages, @@ -302,6 +303,27 @@ public Task WaitForIdleAsync() } } + public string? SessionId + { + get + { + lock (_gate) + { + return _sessionId; + } + } + } + + public void SetSessionId(string? sessionId) + { + lock (_gate) + { + EnsureIdle(); + AgentValidator.ValidateOptions(_model, sessionId, _parameters, _limits, _clock, _runIdFactory); + _sessionId = sessionId; + } + } + public void SetModel(string model) { lock (_gate) @@ -544,6 +566,7 @@ private async ValueTask ProcessEventAsync(AgentEvent agentEvent, CancellationTok try { Subscriber[] subscribers; + CancellationToken subscriberToken; lock (_gate) { switch (agentEvent.Kind) @@ -592,13 +615,14 @@ private async ValueTask ProcessEventAsync(AgentEvent agentEvent, CancellationTok } subscribers = _subscribers.ToArray(); + subscriberToken = _activeCancellation?.Token ?? cancellationToken; } foreach (var subscriber in subscribers) { try { - await subscriber.Handler(agentEvent, cancellationToken).ConfigureAwait(false); + await subscriber.Handler(agentEvent, subscriberToken).ConfigureAwait(false); } catch (Exception exception) { diff --git a/src/OpenGameAgent.Kernel/AgentLoop.cs b/src/OpenGameAgent.Kernel/AgentLoop.cs index 0040393..aa0eeab 100644 --- a/src/OpenGameAgent.Kernel/AgentLoop.cs +++ b/src/OpenGameAgent.Kernel/AgentLoop.cs @@ -296,6 +296,7 @@ await EmitAsync(new AgentEvent( else { batch = await ExecuteToolCallsAsync( + assistantMessage, calls, runId, turns, @@ -633,7 +634,8 @@ private static async Task StreamAssistantAsync( request.Model, options.Clock, limits, - emit).ConfigureAwait(false); + emit, + (exception as ModelProviderException)?.Diagnostics).ConfigureAwait(false); return new AssistantStreamResult(syntheticResponse, request.Model); } finally @@ -733,7 +735,8 @@ private static async Task EmitSyntheticModelFailureAsync( string model, Func clock, AgentLimits limits, - Func emit) + Func emit, + IReadOnlyList? failureDiagnostics = null) { var safeError = string.IsNullOrWhiteSpace(error) ? reason == ModelStopReason.Aborted ? "The model request was aborted." : "The model request failed." @@ -745,7 +748,24 @@ private static async Task EmitSyntheticModelFailureAsync( var safeContent = partial?.Content.Where(part => part is not ToolCallContent).ToArray() ?? Array.Empty(); - var response = new ModelResponse(safeContent, reason, partial?.Usage, safeError); + var diagnostics = partial?.Diagnostics.ToList() ?? new List(); + if (failureDiagnostics is not null) + { + diagnostics.AddRange(failureDiagnostics); + } + + var response = new ModelResponse( + safeContent, + reason, + partial?.Usage, + safeError, + partial?.Provider, + partial?.Api, + partial?.ResponseModel, + partial?.ResponseId, + partial?.RawStopReason, + partial?.EndTurn, + diagnostics); AgentValidator.ValidateResponse(response, limits); var terminal = ModelStreamEvent.Terminal(response); var message = ToAssistantMessage(response, clock(), model); @@ -798,6 +818,7 @@ private static async Task FailUnexecutedToolCallsAsync( } private static async Task ExecuteToolCallsAsync( + AgentMessage assistantMessage, IReadOnlyList calls, string runId, int turn, @@ -824,7 +845,15 @@ private static async Task ExecuteToolCallsAsync( { try { - preparation = await PrepareToolCallAsync(call, current, options, limits, cancellationToken).ConfigureAwait(false); + preparation = await PrepareToolCallAsync( + assistantMessage, + call, + runId, + turn, + current, + options, + limits, + cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -899,6 +928,7 @@ async Task ExecuteBoundedAsync(PreparedToolCall item) var outcome = await ExecutePreparedToolCallAsync( item, + assistantMessage, runId, turn, current.Snapshot(), @@ -970,6 +1000,7 @@ await emit(new AgentEvent( { outcome = await ExecutePreparedToolCallAsync( item, + assistantMessage, runId, turn, current.Snapshot(), @@ -1004,7 +1035,10 @@ await emit(new AgentEvent( } private static async Task PrepareToolCallAsync( + AgentMessage assistantMessage, ToolCallContent originalCall, + string runId, + int turn, MutableLoopContext current, AgentLoopOptions options, AgentLimits limits, @@ -1041,7 +1075,15 @@ private static async Task PrepareToolCallAsync( if (options.Hooks.BeforeToolCallAsync is not null) { - var decision = await options.Hooks.BeforeToolCallAsync(call, current.Snapshot(), cancellationToken).ConfigureAwait(false); + var decision = await options.Hooks.BeforeToolCallAsync( + new BeforeToolCallContext( + runId, + turn, + assistantMessage, + call, + arguments, + current.Snapshot()), + cancellationToken).ConfigureAwait(false); if (decision?.Blocked == true) { return ToolPreparation.Failed(CreateToolError( @@ -1090,6 +1132,7 @@ private static async Task PrepareToolCallAsync( private static async Task ExecutePreparedToolCallAsync( PreparedToolCall prepared, + AgentMessage assistantMessage, string runId, int turn, AgentContext context, @@ -1213,7 +1256,16 @@ private static async Task ExecutePreparedToolCallAsync( { if (options.Hooks.AfterToolCallAsync is not null) { - result = await options.Hooks.AfterToolCallAsync(prepared.Call, result, context, cancellationToken).ConfigureAwait(false) + result = await options.Hooks.AfterToolCallAsync( + new AfterToolCallContext( + runId, + turn, + assistantMessage, + prepared.Call, + prepared.Arguments, + result, + context), + cancellationToken).ConfigureAwait(false) ?? result; uncertainSideEffect |= result.OutcomeUncertain; } @@ -1331,7 +1383,15 @@ private static AgentMessage ToAssistantMessage(ModelResponse response, DateTimeO model: model, stopReason: response.StopReason, usage: response.Usage, - errorMessage: response.ErrorMessage); + errorMessage: response.ErrorMessage, + provider: response.Provider, + api: response.Api, + responseModel: response.ResponseModel, + responseId: response.ResponseId, + rawStopReason: response.RawStopReason, + endTurn: response.EndTurn, + diagnostics: response.Diagnostics, + deferred: response.Deferred); private static async Task EmitMessageAsync( AgentMessage message, diff --git a/src/OpenGameAgent.Kernel/AgentOptions.cs b/src/OpenGameAgent.Kernel/AgentOptions.cs index 01a950c..334292b 100644 --- a/src/OpenGameAgent.Kernel/AgentOptions.cs +++ b/src/OpenGameAgent.Kernel/AgentOptions.cs @@ -35,6 +35,8 @@ public sealed class AgentLimits public int MaxResourceUriCharacters { get; set; } = 16_384; + public int MaxBinaryDataCharactersPerPart { get; set; } = 16_000_000; + public int MaxToolCallsPerTurn { get; set; } = 32; public int MaxTools { get; set; } = 256; @@ -53,6 +55,10 @@ public sealed class AgentLimits public int MaxMetadataValueCharacters { get; set; } = 16_384; + public int MaxDiagnosticsPerMessage { get; set; } = 64; + + public int MaxAddedToolNamesPerResult { get; set; } = 256; + public int MaxQueuedMessages { get; set; } = 64; public int MaxConcurrentTools { get; set; } = 8; @@ -84,6 +90,7 @@ internal void Validate() RequireRange(MaxTextCharactersPerPart, 1, 100_000_000, nameof(MaxTextCharactersPerPart)); RequireRange(MaxJsonCharactersPerPart, 1, 100_000_000, nameof(MaxJsonCharactersPerPart)); RequireRange(MaxResourceUriCharacters, 1, 1_000_000, nameof(MaxResourceUriCharacters)); + RequireRange(MaxBinaryDataCharactersPerPart, 1, 100_000_000, nameof(MaxBinaryDataCharactersPerPart)); RequireRange(MaxToolCallsPerTurn, 1, 10_000, nameof(MaxToolCallsPerTurn)); RequireRange(MaxTools, 0, 100_000, nameof(MaxTools)); RequireRange(MaxToolNameCharacters, 1, 4096, nameof(MaxToolNameCharacters)); @@ -93,6 +100,8 @@ internal void Validate() RequireRange(MaxMetadataEntriesPerMessage, 0, 100_000, nameof(MaxMetadataEntriesPerMessage)); RequireRange(MaxMetadataKeyCharacters, 1, 100_000, nameof(MaxMetadataKeyCharacters)); RequireRange(MaxMetadataValueCharacters, 0, 100_000_000, nameof(MaxMetadataValueCharacters)); + RequireRange(MaxDiagnosticsPerMessage, 0, 10_000, nameof(MaxDiagnosticsPerMessage)); + RequireRange(MaxAddedToolNamesPerResult, 0, 100_000, nameof(MaxAddedToolNamesPerResult)); RequireRange(MaxQueuedMessages, 1, 100_000, nameof(MaxQueuedMessages)); RequireRange(MaxConcurrentTools, 1, 1024, nameof(MaxConcurrentTools)); RequireRange(ToolTimeoutMilliseconds, 1, 86_400_000, nameof(ToolTimeoutMilliseconds)); @@ -188,6 +197,72 @@ public sealed class NextTurnUpdate public ModelParameters? Parameters { get; set; } } +public sealed class BeforeToolCallContext +{ + public BeforeToolCallContext( + string runId, + int turn, + AgentMessage assistantMessage, + ToolCallContent toolCall, + System.Text.Json.JsonElement arguments, + AgentContext context) + { + RunId = runId; + Turn = turn; + AssistantMessage = assistantMessage ?? throw new ArgumentNullException(nameof(assistantMessage)); + ToolCall = toolCall ?? throw new ArgumentNullException(nameof(toolCall)); + Arguments = arguments.Clone(); + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public string RunId { get; } + + public int Turn { get; } + + public AgentMessage AssistantMessage { get; } + + public ToolCallContent ToolCall { get; } + + public System.Text.Json.JsonElement Arguments { get; } + + public AgentContext Context { get; } +} + +public sealed class AfterToolCallContext +{ + public AfterToolCallContext( + string runId, + int turn, + AgentMessage assistantMessage, + ToolCallContent toolCall, + System.Text.Json.JsonElement arguments, + ToolResult result, + AgentContext context) + { + RunId = runId; + Turn = turn; + AssistantMessage = assistantMessage ?? throw new ArgumentNullException(nameof(assistantMessage)); + ToolCall = toolCall ?? throw new ArgumentNullException(nameof(toolCall)); + Arguments = arguments.Clone(); + Result = result ?? throw new ArgumentNullException(nameof(result)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public string RunId { get; } + + public int Turn { get; } + + public AgentMessage AssistantMessage { get; } + + public ToolCallContent ToolCall { get; } + + public System.Text.Json.JsonElement Arguments { get; } + + public ToolResult Result { get; } + + public AgentContext Context { get; } +} + public sealed class AgentHooks { public Func, CancellationToken, ValueTask>>? TransformContextAsync { get; set; } @@ -198,9 +273,9 @@ public sealed class AgentHooks public Func>? PrepareNextTurnAsync { get; set; } - public Func>? BeforeToolCallAsync { get; set; } + public Func>? BeforeToolCallAsync { get; set; } - public Func>? AfterToolCallAsync { get; set; } + public Func>? AfterToolCallAsync { get; set; } } public sealed class AgentOptions @@ -248,6 +323,7 @@ internal AgentState( string systemPrompt, IModelProvider provider, string model, + string? sessionId, ModelParameters parameters, IReadOnlyList tools, IReadOnlyList messages, @@ -260,6 +336,7 @@ internal AgentState( SystemPrompt = systemPrompt; Provider = provider ?? throw new ArgumentNullException(nameof(provider)); Model = model; + SessionId = sessionId; Parameters = parameters?.Copy() ?? throw new ArgumentNullException(nameof(parameters)); Tools = Array.AsReadOnly(tools.ToArray()); Messages = Array.AsReadOnly(messages.ToArray()); @@ -276,6 +353,8 @@ internal AgentState( public string Model { get; } + public string? SessionId { get; } + public ModelParameters Parameters { get; } public IReadOnlyList Tools { get; } diff --git a/src/OpenGameAgent.Kernel/AgentValidator.cs b/src/OpenGameAgent.Kernel/AgentValidator.cs index 3f75e7a..060930a 100644 --- a/src/OpenGameAgent.Kernel/AgentValidator.cs +++ b/src/OpenGameAgent.Kernel/AgentValidator.cs @@ -45,6 +45,62 @@ public static void ValidateOptions( throw new AgentLimitException(nameof(limits.MaxMetadataValueCharacters), "The reasoning level is too large."); } + if (!Enum.IsDefined(typeof(ModelTransport), parameters.Transport) + || !Enum.IsDefined(typeof(ModelCacheRetention), parameters.CacheRetention)) + { + throw new ArgumentOutOfRangeException(nameof(parameters), "The model transport or cache-retention setting is invalid."); + } + + if (parameters.DeferredWindow is { } deferredWindow + && !Enum.IsDefined(typeof(ModelDeferredWindow), deferredWindow)) + { + throw new ArgumentOutOfRangeException(nameof(parameters), "The deferred-response window is invalid."); + } + + if (!parameters.Deferred && parameters.DeferredWindow is not null) + { + throw new ArgumentException("A deferred-response window requires Deferred to be enabled.", nameof(parameters)); + } + + if (parameters.WebSocketConnectTimeoutMilliseconds is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(parameters), "The WebSocket connect timeout must be positive."); + } + + if (parameters.SamplingParametersJson is { } sampling) + { + var validSampling = JsonValue.RequireObject(sampling, nameof(parameters.SamplingParametersJson)); + if (validSampling.Length > limits.MaxJsonCharactersPerPart) + { + throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Sampling parameters are too large."); + } + } + + if (parameters.MetadataJson is { } metadata) + { + var validMetadata = JsonValue.RequireObject(metadata, nameof(parameters.MetadataJson)); + if (validMetadata.Length > limits.MaxJsonCharactersPerPart) + { + throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Model metadata is too large."); + } + } + + var reasoningBudgets = parameters.ReasoningBudgets ?? new Dictionary(); + if (reasoningBudgets.Count > 64) + { + throw new AgentLimitException(nameof(limits.MaxMetadataEntriesPerMessage), "Too many reasoning budgets are configured."); + } + + foreach (var budget in reasoningBudgets) + { + if (string.IsNullOrWhiteSpace(budget.Key) + || budget.Key.Length > limits.MaxMetadataKeyCharacters + || budget.Value <= 0) + { + throw new ArgumentException("Reasoning budgets require bounded names and positive token counts.", nameof(parameters)); + } + } + var extensions = parameters.Extensions ?? new Dictionary(); if (extensions.Count > limits.MaxMetadataEntriesPerMessage) { @@ -132,6 +188,8 @@ public static void ValidateContext(AgentContext context, AgentLimits limits) throw new AgentLimitException(nameof(limits.MaxToolSchemaCharacters), $"Tool schema '{definition.Name}' is too large."); } + ValidateConstrainedSampling(definition, limits); + if (!names.Add(definition.Name)) { throw new ArgumentException($"Duplicate tool name '{definition.Name}'.", nameof(context)); @@ -213,6 +271,38 @@ public static void ValidateMessage(AgentMessage message, AgentLimits limits) throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A message error is too large."); } + if (message.Diagnostics.Count > limits.MaxDiagnosticsPerMessage) + { + throw new AgentLimitException(nameof(limits.MaxDiagnosticsPerMessage), "A message contains too many diagnostics."); + } + + foreach (var diagnostic in message.Diagnostics) + { + ValidateDiagnostic(diagnostic, limits); + } + + if (message.AddedToolNames.Count > limits.MaxAddedToolNamesPerResult) + { + throw new AgentLimitException(nameof(limits.MaxAddedToolNamesPerResult), "A tool result exposes too many new tool names."); + } + + foreach (var name in message.AddedToolNames) + { + if (name.Length > limits.MaxToolNameCharacters) + { + throw new AgentLimitException(nameof(limits.MaxToolNameCharacters), "An added tool name is too large."); + } + } + + ValidateResponseIdentity( + message.Provider, + message.Api, + message.ResponseModel, + message.ResponseId, + message.RawStopReason, + message.Deferred, + limits); + } @@ -254,6 +344,25 @@ public static void ValidateResponse(ModelResponse response, AgentLimits limits) throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "The model response error is too large."); } + if (response.Diagnostics.Count > limits.MaxDiagnosticsPerMessage) + { + throw new AgentLimitException(nameof(limits.MaxDiagnosticsPerMessage), "The model response contains too many diagnostics."); + } + + foreach (var diagnostic in response.Diagnostics) + { + ValidateDiagnostic(diagnostic, limits); + } + + ValidateResponseIdentity( + response.Provider, + response.Api, + response.ResponseModel, + response.ResponseId, + response.RawStopReason, + response.Deferred, + limits); + var callIds = new HashSet(StringComparer.Ordinal); @@ -295,6 +404,19 @@ public static void ValidateToolResult(ToolResult result, AgentLimits limits) throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Tool result details are too large."); } + if (result.AddedToolNames.Count > limits.MaxAddedToolNamesPerResult) + { + throw new AgentLimitException(nameof(limits.MaxAddedToolNamesPerResult), "A tool result exposes too many new tool names."); + } + + foreach (var name in result.AddedToolNames) + { + if (name.Length > limits.MaxToolNameCharacters) + { + throw new AgentLimitException(nameof(limits.MaxToolNameCharacters), "An added tool name is too large."); + } + } + } @@ -314,6 +436,18 @@ public static void ValidateProgress(ToolProgress progress, AgentLimits limits) { throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Tool progress details are too large."); } + + if (progress.Content.Count > limits.MaxContentPartsPerMessage) + { + throw new AgentLimitException( + nameof(limits.MaxContentPartsPerMessage), + "Tool progress contains too many content parts."); + } + + foreach (var content in progress.Content) + { + ValidateContent(content, limits); + } } public static void ValidateRequest( @@ -360,6 +494,8 @@ public static void ValidateRequest( throw new AgentLimitException(nameof(limits.MaxTools), "A provider tool definition exceeds configured limits."); } + ValidateConstrainedSampling(tool, limits); + if (!names.Add(tool.Name)) { throw new ArgumentException($"Duplicate provider tool name '{tool.Name}'.", nameof(request)); @@ -375,6 +511,8 @@ private static void ValidateContent(AgentContent content, AgentLimits limits) throw new ArgumentException("Content cannot be null.", nameof(content)); case TextContent text when text.Text.Length > limits.MaxTextCharactersPerPart: throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A text content part is too large."); + case TextContent text when (text.Signature?.Length ?? 0) > limits.MaxTextCharactersPerPart: + throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A text signature is too large."); case ReasoningContent reasoning when reasoning.Text.Length > limits.MaxTextCharactersPerPart: throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A reasoning content part is too large."); case ReasoningContent reasoning when (reasoning.Signature?.Length ?? 0) > limits.MaxTextCharactersPerPart: @@ -387,19 +525,101 @@ private static void ValidateContent(AgentContent content, AgentLimits limits) throw new AgentLimitException(nameof(limits.MaxMetadataValueCharacters), "A resource media type is too large."); case ResourceContent resource when (resource.Name?.Length ?? 0) > limits.MaxTextCharactersPerPart: throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A resource name is too large."); + case BinaryContent binary when binary.Data.Length > limits.MaxBinaryDataCharactersPerPart: + throw new AgentLimitException(nameof(limits.MaxBinaryDataCharactersPerPart), "An inline media part is too large."); + case BinaryContent binary when binary.MediaType.Length > limits.MaxMetadataValueCharacters: + throw new AgentLimitException(nameof(limits.MaxMetadataValueCharacters), "An inline media type is too large."); + case BinaryContent binary when (binary.Name?.Length ?? 0) > limits.MaxTextCharactersPerPart: + throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "An inline media name is too large."); case ToolCallContent call when call.Id.Length > limits.MaxToolCallIdCharacters: throw new AgentLimitException(nameof(limits.MaxToolCallIdCharacters), "A tool call ID is too large."); case ToolCallContent call when call.Name.Length > limits.MaxToolNameCharacters: throw new AgentLimitException(nameof(limits.MaxToolNameCharacters), "A tool call name is too large."); case ToolCallContent call when call.ArgumentsJson.Length > limits.MaxJsonCharactersPerPart: throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Tool call arguments are too large."); - case TextContent or ReasoningContent or JsonContent or ResourceContent or ToolCallContent: + case ToolCallContent call when (call.ThoughtSignature?.Length ?? 0) > limits.MaxTextCharactersPerPart: + throw new AgentLimitException(nameof(limits.MaxTextCharactersPerPart), "A tool-call thought signature is too large."); + case ToolCallContent call when (call.Namespace?.Length ?? 0) > limits.MaxToolNameCharacters: + throw new AgentLimitException(nameof(limits.MaxToolNameCharacters), "A tool-call namespace is too large."); + case TextContent or ReasoningContent or JsonContent or ResourceContent or BinaryContent or ToolCallContent: break; default: throw new ArgumentException($"Unsupported content type '{content.GetType().FullName}'.", nameof(content)); } } + private static void ValidateDiagnostic(ModelDiagnostic diagnostic, AgentLimits limits) + { + if (diagnostic.Code.Length > limits.MaxMetadataKeyCharacters + || diagnostic.Message.Length > limits.MaxTextCharactersPerPart) + { + throw new AgentLimitException(nameof(limits.MaxDiagnosticsPerMessage), "A model diagnostic is too large."); + } + + if ((diagnostic.DataJson?.Length ?? 0) > limits.MaxJsonCharactersPerPart) + { + throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Model diagnostic data is too large."); + } + } + + private static void ValidateConstrainedSampling(ToolDefinition definition, AgentLimits limits) + { + var constrained = definition.ConstrainedSampling; + if (constrained is null) + { + return; + } + + if (!Enum.IsDefined(typeof(ToolConstrainedSamplingKind), constrained.Kind) + || (constrained.Strictness is { } strictness + && !Enum.IsDefined(typeof(ToolSchemaStrictness), strictness))) + { + throw new ArgumentException("A tool has invalid constrained-sampling settings.", nameof(definition)); + } + + if ((constrained.OpenAiLark?.Length ?? 0) > limits.MaxToolSchemaCharacters + || (constrained.OpenAiRegex?.Length ?? 0) > limits.MaxToolSchemaCharacters) + { + throw new AgentLimitException(nameof(limits.MaxToolSchemaCharacters), "A constrained-sampling grammar is too large."); + } + } + + private static void ValidateResponseIdentity( + string? provider, + string? api, + string? responseModel, + string? responseId, + string? rawStopReason, + DeferredModelHandle? deferred, + AgentLimits limits) + { + foreach (var value in new[] { provider, api, responseModel, responseId, rawStopReason }) + { + if ((value?.Length ?? 0) > limits.MaxMetadataValueCharacters) + { + throw new AgentLimitException(nameof(limits.MaxMetadataValueCharacters), "Model response identity is too large."); + } + } + + if (deferred is null) + { + return; + } + + foreach (var value in new[] { deferred.Provider, deferred.Model, deferred.Api, deferred.Id }) + { + if (value.Length > limits.MaxMetadataValueCharacters) + { + throw new AgentLimitException(nameof(limits.MaxMetadataValueCharacters), "A deferred response handle is too large."); + } + } + + if ((deferred.DataJson?.Length ?? 0) > limits.MaxJsonCharactersPerPart) + { + throw new AgentLimitException(nameof(limits.MaxJsonCharactersPerPart), "Deferred response data is too large."); + } + } + public static void ValidateTranscript(IReadOnlyList messages) { var openCalls = new Dictionary(StringComparer.Ordinal); diff --git a/src/OpenGameAgent.Kernel/Content.cs b/src/OpenGameAgent.Kernel/Content.cs index 150f0b2..2ea7372 100644 --- a/src/OpenGameAgent.Kernel/Content.cs +++ b/src/OpenGameAgent.Kernel/Content.cs @@ -8,10 +8,25 @@ public enum AgentContentKind Text, Json, Resource, + Binary, Reasoning, ToolCall, } +public enum AgentTextPhase +{ + Commentary, + FinalAnswer, +} + +public enum AgentMediaKind +{ + Image, + Audio, + Video, + File, +} + public abstract class AgentContent { protected AgentContent(AgentContentKind kind) @@ -24,13 +39,24 @@ protected AgentContent(AgentContentKind kind) public sealed class TextContent : AgentContent { - public TextContent(string text) + public TextContent(string text, string? signature = null, AgentTextPhase? phase = null) : base(AgentContentKind.Text) { Text = text ?? throw new ArgumentNullException(nameof(text)); + if (phase is { } value && !Enum.IsDefined(typeof(AgentTextPhase), value)) + { + throw new ArgumentOutOfRangeException(nameof(phase)); + } + + Signature = signature; + Phase = phase; } public string Text { get; } + + public string? Signature { get; } + + public AgentTextPhase? Phase { get; } } public sealed class JsonContent : AgentContent @@ -73,23 +99,70 @@ public ResourceContent(string uri, string mediaType, string? name = null) public string? Name { get; } } +public sealed class BinaryContent : AgentContent +{ + public BinaryContent( + AgentMediaKind mediaKind, + string data, + string mediaType, + string? name = null) + : base(AgentContentKind.Binary) + { + if (!Enum.IsDefined(typeof(AgentMediaKind), mediaKind)) + { + throw new ArgumentOutOfRangeException(nameof(mediaKind)); + } + + if (string.IsNullOrWhiteSpace(data)) + { + throw new ArgumentException("Base64-encoded media data is required.", nameof(data)); + } + + if (string.IsNullOrWhiteSpace(mediaType)) + { + throw new ArgumentException("A media type is required.", nameof(mediaType)); + } + + MediaKind = mediaKind; + Data = data; + MediaType = mediaType; + Name = name; + } + + public AgentMediaKind MediaKind { get; } + + public string Data { get; } + + public string MediaType { get; } + + public string? Name { get; } +} + public sealed class ReasoningContent : AgentContent { - public ReasoningContent(string text, string? signature = null) + public ReasoningContent(string text, string? signature = null, bool redacted = false) : base(AgentContentKind.Reasoning) { Text = text ?? throw new ArgumentNullException(nameof(text)); Signature = signature; + Redacted = redacted; } public string Text { get; } public string? Signature { get; } + + public bool Redacted { get; } } public sealed class ToolCallContent : AgentContent { - public ToolCallContent(string id, string name, string argumentsJson) + public ToolCallContent( + string id, + string name, + string argumentsJson, + string? thoughtSignature = null, + string? toolNamespace = null) : base(AgentContentKind.ToolCall) { if (string.IsNullOrWhiteSpace(id)) @@ -105,6 +178,8 @@ public ToolCallContent(string id, string name, string argumentsJson) Id = id; Name = name; ArgumentsJson = JsonValue.RequireObject(argumentsJson, nameof(argumentsJson)); + ThoughtSignature = thoughtSignature; + Namespace = toolNamespace; } public string Id { get; } @@ -112,6 +187,10 @@ public ToolCallContent(string id, string name, string argumentsJson) public string Name { get; } public string ArgumentsJson { get; } + + public string? ThoughtSignature { get; } + + public string? Namespace { get; } } internal static class JsonValue diff --git a/src/OpenGameAgent.Kernel/Messages.cs b/src/OpenGameAgent.Kernel/Messages.cs index 90e6308..614baf0 100644 --- a/src/OpenGameAgent.Kernel/Messages.cs +++ b/src/OpenGameAgent.Kernel/Messages.cs @@ -28,7 +28,16 @@ public AgentMessage( string? model = null, ModelStopReason? stopReason = null, ModelUsage? usage = null, - string? errorMessage = null) + string? errorMessage = null, + string? provider = null, + string? api = null, + string? responseModel = null, + string? responseId = null, + string? rawStopReason = null, + bool? endTurn = null, + IEnumerable? diagnostics = null, + DeferredModelHandle? deferred = null, + IEnumerable? addedToolNames = null) { if (!Enum.IsDefined(typeof(AgentRole), role)) { @@ -55,12 +64,28 @@ public AgentMessage( throw new ArgumentException("A tool result message requires a tool call ID and tool name."); } - if (role != AgentRole.Tool && (toolCallId is not null || toolName is not null || isError || detailsJson is not null)) + if (role != AgentRole.Tool + && (toolCallId is not null + || toolName is not null + || isError + || detailsJson is not null + || addedToolNames is not null)) { throw new ArgumentException("Only a tool result message can carry tool-result fields."); } - if (role != AgentRole.Assistant && (model is not null || stopReason is not null || errorMessage is not null)) + if (role != AgentRole.Assistant + && (model is not null + || stopReason is not null + || errorMessage is not null + || provider is not null + || api is not null + || responseModel is not null + || responseId is not null + || rawStopReason is not null + || endTurn is not null + || diagnostics is not null + || deferred is not null)) { throw new ArgumentException("Only an assistant message can carry model response fields."); } @@ -84,6 +109,16 @@ public AgentMessage( throw new ArgumentException("Only an assistant or tool result message can carry usage.", nameof(usage)); } + if (role == AgentRole.Assistant && stopReason == ModelStopReason.Deferred && deferred is null) + { + throw new ArgumentException("A deferred assistant message requires a deferred handle.", nameof(deferred)); + } + + if (role == AgentRole.Assistant && stopReason != ModelStopReason.Deferred && deferred is not null) + { + throw new ArgumentException("Only a deferred assistant message can carry a deferred handle.", nameof(deferred)); + } + var copiedContent = content?.ToArray() ?? throw new ArgumentNullException(nameof(content)); if (copiedContent.Any(part => part is null)) { @@ -115,10 +150,32 @@ public AgentMessage( } Metadata = new ReadOnlyDictionary(copiedMetadata); + var copiedDiagnostics = diagnostics?.ToArray() ?? Array.Empty(); + if (copiedDiagnostics.Any(diagnostic => diagnostic is null)) + { + throw new ArgumentException("Message diagnostics cannot contain null values.", nameof(diagnostics)); + } + + var copiedAddedTools = addedToolNames?.ToArray() ?? Array.Empty(); + if (copiedAddedTools.Any(string.IsNullOrWhiteSpace) + || copiedAddedTools.Distinct(StringComparer.Ordinal).Count() != copiedAddedTools.Length) + { + throw new ArgumentException("Added tool names must be non-empty and unique.", nameof(addedToolNames)); + } + Model = model; StopReason = stopReason; Usage = usage; ErrorMessage = errorMessage; + Provider = provider; + Api = api; + ResponseModel = responseModel; + ResponseId = responseId; + RawStopReason = rawStopReason; + EndTurn = endTurn; + Diagnostics = Array.AsReadOnly(copiedDiagnostics); + Deferred = deferred; + AddedToolNames = Array.AsReadOnly(copiedAddedTools); } public AgentRole Role { get; } @@ -147,6 +204,24 @@ public AgentMessage( public string? ErrorMessage { get; } + public string? Provider { get; } + + public string? Api { get; } + + public string? ResponseModel { get; } + + public string? ResponseId { get; } + + public string? RawStopReason { get; } + + public bool? EndTurn { get; } + + public IReadOnlyList Diagnostics { get; } + + public DeferredModelHandle? Deferred { get; } + + public IReadOnlyList AddedToolNames { get; } + public static AgentMessage User( string text, DateTimeOffset? timestamp = null, @@ -190,6 +265,7 @@ public static AgentMessage ToolResult( toolName: call.Name, isError: result.IsError, detailsJson: result.DetailsJson, - usage: result.Usage); + usage: result.Usage, + addedToolNames: result.AddedToolNames); } } diff --git a/src/OpenGameAgent.Kernel/Models.cs b/src/OpenGameAgent.Kernel/Models.cs index df5957e..4099616 100644 --- a/src/OpenGameAgent.Kernel/Models.cs +++ b/src/OpenGameAgent.Kernel/Models.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Net.Http; using System.Threading; namespace OpenGameAgent.Kernel; @@ -15,14 +14,104 @@ public enum ModelStopReason Length, Error, Aborted, + Deferred, +} + +public enum ModelTransport +{ + Auto, + ServerSentEvents, + WebSocket, + CachedWebSocket, +} + +public enum ModelCacheRetention +{ + None, + Short, + Long, +} + +public enum ModelDeferredWindow +{ + FifteenMinutes, + OneHour, + TwentyFourHours, +} + +public sealed class ModelCost +{ + public ModelCost( + double input = 0, + double output = 0, + double cacheRead = 0, + double cacheWrite = 0) + { + Input = RequireAmount(input, nameof(input)); + Output = RequireAmount(output, nameof(output)); + CacheRead = RequireAmount(cacheRead, nameof(cacheRead)); + CacheWrite = RequireAmount(cacheWrite, nameof(cacheWrite)); + } + + public double Input { get; } + + public double Output { get; } + + public double CacheRead { get; } + + public double CacheWrite { get; } + + public double Total => Input + Output + CacheRead + CacheWrite; + + internal static ModelCost Aggregate(IEnumerable values) + { + var input = 0d; + var output = 0d; + var cacheRead = 0d; + var cacheWrite = 0d; + foreach (var value in values) + { + input += value.Input; + output += value.Output; + cacheRead += value.CacheRead; + cacheWrite += value.CacheWrite; + } + + return new ModelCost(input, output, cacheRead, cacheWrite); + } + + private static double RequireAmount(double value, string name) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0) + { + throw new ArgumentOutOfRangeException(name, "Model costs must be finite and non-negative."); + } + + return value; + } } public sealed class ModelUsage { private const long MaximumCombinedTokens = 10_000_000_000; - public ModelUsage(long inputTokens = 0, long outputTokens = 0, long cacheReadTokens = 0, long cacheWriteTokens = 0) - : this(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, enforceSingleReportLimit: true) + public ModelUsage( + long inputTokens = 0, + long outputTokens = 0, + long cacheReadTokens = 0, + long cacheWriteTokens = 0, + long? reasoningTokens = null, + long? cacheWriteOneHourTokens = null, + ModelCost? cost = null) + : this( + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens, + cacheWriteOneHourTokens, + cost, + enforceSingleReportLimit: true) { } @@ -31,6 +120,9 @@ private ModelUsage( long outputTokens, long cacheReadTokens, long cacheWriteTokens, + long? reasoningTokens, + long? cacheWriteOneHourTokens, + ModelCost? cost, bool enforceSingleReportLimit) { if (inputTokens < 0 || outputTokens < 0 || cacheReadTokens < 0 || cacheWriteTokens < 0) @@ -38,6 +130,18 @@ private ModelUsage( throw new ArgumentOutOfRangeException(nameof(inputTokens), "Token counts cannot be negative."); } + if (reasoningTokens is < 0 || reasoningTokens > outputTokens) + { + throw new ArgumentOutOfRangeException(nameof(reasoningTokens), "Reasoning tokens must be a subset of output tokens."); + } + + if (cacheWriteOneHourTokens is < 0 || cacheWriteOneHourTokens > cacheWriteTokens) + { + throw new ArgumentOutOfRangeException( + nameof(cacheWriteOneHourTokens), + "One-hour cache writes must be a subset of cache-write tokens."); + } + try { var total = checked(inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens); @@ -55,6 +159,9 @@ private ModelUsage( OutputTokens = outputTokens; CacheReadTokens = cacheReadTokens; CacheWriteTokens = cacheWriteTokens; + ReasoningTokens = reasoningTokens; + CacheWriteOneHourTokens = cacheWriteOneHourTokens; + Cost = cost ?? new ModelCost(); } public long InputTokens { get; } @@ -65,6 +172,12 @@ private ModelUsage( public long CacheWriteTokens { get; } + public long? ReasoningTokens { get; } + + public long? CacheWriteOneHourTokens { get; } + + public ModelCost Cost { get; } + public long TotalTokens => checked(InputTokens + OutputTokens + CacheReadTokens + CacheWriteTokens); internal static ModelUsage Aggregate(IEnumerable values) @@ -73,15 +186,41 @@ internal static ModelUsage Aggregate(IEnumerable values) var output = 0L; var cacheRead = 0L; var cacheWrite = 0L; + var reasoning = 0L; + var cacheWriteOneHour = 0L; + var hasReasoning = false; + var hasCacheWriteOneHour = false; + var costs = new List(); foreach (var value in values) { input = checked(input + value.InputTokens); output = checked(output + value.OutputTokens); cacheRead = checked(cacheRead + value.CacheReadTokens); cacheWrite = checked(cacheWrite + value.CacheWriteTokens); + if (value.ReasoningTokens is { } reasoningValue) + { + reasoning = checked(reasoning + reasoningValue); + hasReasoning = true; + } + + if (value.CacheWriteOneHourTokens is { } longWriteValue) + { + cacheWriteOneHour = checked(cacheWriteOneHour + longWriteValue); + hasCacheWriteOneHour = true; + } + + costs.Add(value.Cost); } - return new ModelUsage(input, output, cacheRead, cacheWrite, enforceSingleReportLimit: false); + return new ModelUsage( + input, + output, + cacheRead, + cacheWrite, + hasReasoning ? reasoning : null, + hasCacheWriteOneHour ? cacheWriteOneHour : null, + ModelCost.Aggregate(costs), + enforceSingleReportLimit: false); } } @@ -93,6 +232,23 @@ public sealed class ModelParameters public string? ReasoningLevel { get; set; } + public IReadOnlyDictionary ReasoningBudgets { get; set; } = + new ReadOnlyDictionary(new Dictionary()); + + public string? SamplingParametersJson { get; set; } + + public ModelTransport Transport { get; set; } = ModelTransport.Auto; + + public ModelCacheRetention CacheRetention { get; set; } = ModelCacheRetention.Short; + + public int? WebSocketConnectTimeoutMilliseconds { get; set; } + + public bool Deferred { get; set; } + + public ModelDeferredWindow? DeferredWindow { get; set; } + + public string? MetadataJson { get; set; } + public IReadOnlyDictionary Extensions { get; set; } = new ReadOnlyDictionary(new Dictionary()); @@ -103,6 +259,19 @@ public ModelParameters Clone() Temperature = Temperature, MaxOutputTokens = MaxOutputTokens, ReasoningLevel = ReasoningLevel, + ReasoningBudgets = new ReadOnlyDictionary( + new Dictionary(ReasoningBudgets ?? new Dictionary(), StringComparer.Ordinal)), + SamplingParametersJson = SamplingParametersJson is null + ? null + : JsonValue.RequireObject(SamplingParametersJson, nameof(SamplingParametersJson)), + Transport = Transport, + CacheRetention = CacheRetention, + WebSocketConnectTimeoutMilliseconds = WebSocketConnectTimeoutMilliseconds, + Deferred = Deferred, + DeferredWindow = DeferredWindow, + MetadataJson = MetadataJson is null + ? null + : JsonValue.RequireObject(MetadataJson, nameof(MetadataJson)), Extensions = new ReadOnlyDictionary( new Dictionary(Extensions ?? new Dictionary(), StringComparer.Ordinal)), }; @@ -161,13 +330,111 @@ public ModelRequest( public int Turn { get; } } +public enum ModelDiagnosticSeverity +{ + Information, + Warning, + Error, +} + +public sealed class ModelDiagnostic +{ + public ModelDiagnostic( + string code, + string message, + ModelDiagnosticSeverity severity = ModelDiagnosticSeverity.Information, + string? dataJson = null) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("A diagnostic code is required.", nameof(code)); + } + + if (string.IsNullOrWhiteSpace(message)) + { + throw new ArgumentException("A diagnostic message is required.", nameof(message)); + } + + if (!Enum.IsDefined(typeof(ModelDiagnosticSeverity), severity)) + { + throw new ArgumentOutOfRangeException(nameof(severity)); + } + + Code = code; + Message = message; + Severity = severity; + DataJson = dataJson is null ? null : JsonValue.RequireValid(dataJson, nameof(dataJson)); + } + + public string Code { get; } + + public string Message { get; } + + public ModelDiagnosticSeverity Severity { get; } + + public string? DataJson { get; } +} + +public sealed class DeferredModelHandle +{ + public DeferredModelHandle( + string provider, + string model, + string api, + string id, + DateTimeOffset? expiresAt = null, + int? pollAfterMilliseconds = null, + string? dataJson = null) + { + Provider = RequireIdentifier(provider, nameof(provider)); + Model = RequireIdentifier(model, nameof(model)); + Api = RequireIdentifier(api, nameof(api)); + Id = RequireIdentifier(id, nameof(id)); + if (pollAfterMilliseconds is < 0) + { + throw new ArgumentOutOfRangeException(nameof(pollAfterMilliseconds)); + } + + ExpiresAt = expiresAt; + PollAfterMilliseconds = pollAfterMilliseconds; + DataJson = dataJson is null ? null : JsonValue.RequireValid(dataJson, nameof(dataJson)); + } + + public string Provider { get; } + + public string Model { get; } + + public string Api { get; } + + public string Id { get; } + + public DateTimeOffset? ExpiresAt { get; } + + public int? PollAfterMilliseconds { get; } + + public string? DataJson { get; } + + private static string RequireIdentifier(string value, string name) => + string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("A non-empty identifier is required.", name) + : value; +} + public sealed class ModelResponse { public ModelResponse( IEnumerable content, ModelStopReason stopReason, ModelUsage? usage = null, - string? errorMessage = null) + string? errorMessage = null, + string? provider = null, + string? api = null, + string? responseModel = null, + string? responseId = null, + string? rawStopReason = null, + bool? endTurn = null, + IEnumerable? diagnostics = null, + DeferredModelHandle? deferred = null) { if (!Enum.IsDefined(typeof(ModelStopReason), stopReason)) { @@ -202,10 +469,34 @@ public ModelResponse( throw new ArgumentException("Only an error or aborted response can carry an error message.", nameof(errorMessage)); } + if (stopReason == ModelStopReason.Deferred && deferred is null) + { + throw new ArgumentException("A deferred response requires a deferred handle.", nameof(deferred)); + } + + if (stopReason != ModelStopReason.Deferred && deferred is not null) + { + throw new ArgumentException("Only a deferred response can carry a deferred handle.", nameof(deferred)); + } + + var copiedDiagnostics = diagnostics?.ToArray() ?? Array.Empty(); + if (copiedDiagnostics.Any(diagnostic => diagnostic is null)) + { + throw new ArgumentException("Response diagnostics cannot contain null values.", nameof(diagnostics)); + } + Content = Array.AsReadOnly(copied); StopReason = stopReason; Usage = usage ?? new ModelUsage(); ErrorMessage = errorMessage; + Provider = provider; + Api = api; + ResponseModel = responseModel; + ResponseId = responseId; + RawStopReason = rawStopReason; + EndTurn = endTurn; + Diagnostics = Array.AsReadOnly(copiedDiagnostics); + Deferred = deferred; } public IReadOnlyList Content { get; } @@ -215,6 +506,22 @@ public ModelResponse( public ModelUsage Usage { get; } public string? ErrorMessage { get; } + + public string? Provider { get; } + + public string? Api { get; } + + public string? ResponseModel { get; } + + public string? ResponseId { get; } + + public string? RawStopReason { get; } + + public bool? EndTurn { get; } + + public IReadOnlyList Diagnostics { get; } + + public DeferredModelHandle? Deferred { get; } } public enum ModelStreamEventKind @@ -242,7 +549,9 @@ private ModelStreamEvent( string? delta, int contentIndex, string? toolCallId, - string? toolName) + string? toolName, + ToolCallContent? toolCall, + string? content) { Kind = kind; Partial = partial; @@ -251,6 +560,8 @@ private ModelStreamEvent( ContentIndex = contentIndex; ToolCallId = toolCallId; ToolName = toolName; + ToolCall = toolCall; + Content = content; } public ModelStreamEventKind Kind { get; } @@ -267,6 +578,10 @@ private ModelStreamEvent( public string? ToolName { get; } + public ToolCallContent? ToolCall { get; } + + public string? Content { get; } + public bool IsTerminal => Kind == ModelStreamEventKind.Completed || Kind == ModelStreamEventKind.Failed; public static ModelStreamEvent Update( @@ -275,7 +590,9 @@ public static ModelStreamEvent Update( string? delta = null, int contentIndex = 0, string? toolCallId = null, - string? toolName = null) + string? toolName = null, + ToolCallContent? toolCall = null, + string? content = null) { if (kind == ModelStreamEventKind.Completed || kind == ModelStreamEventKind.Failed) { @@ -310,6 +627,96 @@ or ModelStreamEventKind.ToolCallDelta throw new ArgumentException("A delta stream event requires delta content.", nameof(delta)); } + if (kind == ModelStreamEventKind.ToolCallEnded) + { + if (toolCall is null) + { + throw new ArgumentException("A tool-call end event requires the completed tool call.", nameof(toolCall)); + } + + if (toolCallId is not null && !string.Equals(toolCallId, toolCall.Id, StringComparison.Ordinal)) + { + throw new ArgumentException("The tool-call ID must match the completed tool call.", nameof(toolCallId)); + } + + if (toolName is not null && !string.Equals(toolName, toolCall.Name, StringComparison.Ordinal)) + { + throw new ArgumentException("The tool name must match the completed tool call.", nameof(toolName)); + } + + toolCallId = toolCall.Id; + toolName = toolCall.Name; + } + else if (toolCall is not null) + { + throw new ArgumentException("Only a tool-call end event can carry a completed tool call.", nameof(toolCall)); + } + + if (kind is ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded) + { + if (content is null) + { + throw new ArgumentException("A text or reasoning end event requires the completed content.", nameof(content)); + } + } + else if (content is not null) + { + throw new ArgumentException("Only a text or reasoning end event can carry completed content.", nameof(content)); + } + + var expectedContentKind = kind switch + { + ModelStreamEventKind.TextStarted or ModelStreamEventKind.TextDelta or ModelStreamEventKind.TextEnded => + AgentContentKind.Text, + ModelStreamEventKind.ReasoningStarted or ModelStreamEventKind.ReasoningDelta or ModelStreamEventKind.ReasoningEnded => + AgentContentKind.Reasoning, + ModelStreamEventKind.ToolCallStarted or ModelStreamEventKind.ToolCallDelta or ModelStreamEventKind.ToolCallEnded => + AgentContentKind.ToolCall, + _ => (AgentContentKind?)null, + }; + if (expectedContentKind is { } expected) + { + if (contentIndex >= partial.Content.Count || partial.Content[contentIndex].Kind != expected) + { + throw new ArgumentException( + "A content stream event must reference the matching block in the partial response.", + nameof(contentIndex)); + } + + if (partial.Content[contentIndex] is ToolCallContent partialCall) + { + if (toolCallId is not null && !string.Equals(toolCallId, partialCall.Id, StringComparison.Ordinal)) + { + throw new ArgumentException("The tool-call ID must match the partial response.", nameof(toolCallId)); + } + + if (toolName is not null && !string.Equals(toolName, partialCall.Name, StringComparison.Ordinal)) + { + throw new ArgumentException("The tool name must match the partial response.", nameof(toolName)); + } + + toolCallId ??= partialCall.Id; + toolName ??= partialCall.Name; + + if (toolCall is not null && !EquivalentToolCall(toolCall, partialCall)) + { + throw new ArgumentException( + "The completed tool call must match the partial response.", + nameof(toolCall)); + } + } + else if (partial.Content[contentIndex] is TextContent text && content is not null && content != text.Text) + { + throw new ArgumentException("The completed text must match the partial response.", nameof(content)); + } + else if (partial.Content[contentIndex] is ReasoningContent reasoning + && content is not null + && content != reasoning.Text) + { + throw new ArgumentException("The completed reasoning must match the partial response.", nameof(content)); + } + } + return new ModelStreamEvent( kind, partial, @@ -317,9 +724,18 @@ or ModelStreamEventKind.ToolCallDelta delta, contentIndex, toolCallId, - toolName); + toolName, + toolCall, + content); } + private static bool EquivalentToolCall(ToolCallContent left, ToolCallContent right) => + string.Equals(left.Id, right.Id, StringComparison.Ordinal) + && string.Equals(left.Name, right.Name, StringComparison.Ordinal) + && string.Equals(left.ArgumentsJson, right.ArgumentsJson, StringComparison.Ordinal) + && string.Equals(left.ThoughtSignature, right.ThoughtSignature, StringComparison.Ordinal) + && string.Equals(left.Namespace, right.Namespace, StringComparison.Ordinal); + public static ModelStreamEvent Terminal(ModelResponse response) { if (response is null) @@ -335,7 +751,7 @@ public static ModelStreamEvent Terminal(ModelResponse response) var kind = response.StopReason == ModelStopReason.Error || response.StopReason == ModelStopReason.Aborted ? ModelStreamEventKind.Failed : ModelStreamEventKind.Completed; - return new ModelStreamEvent(kind, null, response, null, 0, null, null); + return new ModelStreamEvent(kind, null, response, null, 0, null, null, null, null); } } @@ -344,7 +760,28 @@ public interface IModelProvider IAsyncEnumerable StreamAsync(ModelRequest request, CancellationToken cancellationToken); } -public sealed class ModelProviderException : HttpRequestException +public interface IDeferredModelProvider : IModelProvider +{ + IAsyncEnumerable FetchDeferredAsync( + DeferredModelHandle handle, + TimeSpan wait, + CancellationToken cancellationToken); + + ValueTask CancelDeferredAsync( + DeferredModelHandle handle, + CancellationToken cancellationToken); +} + +public interface IModelProviderCapabilities +{ + IReadOnlyCollection SupportedApis { get; } + + bool SupportsNativeDeferredTools { get; } + + bool SupportsDeferredResponses { get; } +} + +public sealed class ModelProviderException : Exception { public ModelProviderException( string message, @@ -362,6 +799,41 @@ public ModelProviderException( IsTransient = isTransient; RetryAfter = retryAfter; StatusCode = statusCode; + Diagnostics = Array.Empty(); + } + + public ModelProviderException( + string message, + IEnumerable? diagnostics, + Exception? innerException = null) + : this(message, diagnostics, false, null, null, innerException) + { + } + + public ModelProviderException( + string message, + IEnumerable? diagnostics, + bool isTransient, + TimeSpan? retryAfter = null, + int? statusCode = null, + Exception? innerException = null) + : base(string.IsNullOrWhiteSpace(message) ? "The model provider failed." : message, innerException) + { + if (retryAfter is { } delay && delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(retryAfter)); + } + + var copied = diagnostics?.ToArray() ?? Array.Empty(); + if (copied.Any(diagnostic => diagnostic is null)) + { + throw new ArgumentException("Provider failure diagnostics cannot contain null values.", nameof(diagnostics)); + } + + IsTransient = isTransient; + RetryAfter = retryAfter; + StatusCode = statusCode; + Diagnostics = Array.AsReadOnly(copied); } public bool IsTransient { get; } @@ -369,4 +841,6 @@ public ModelProviderException( public TimeSpan? RetryAfter { get; } public int? StatusCode { get; } + + public IReadOnlyList Diagnostics { get; } } diff --git a/src/OpenGameAgent.Kernel/ProviderTranscript.cs b/src/OpenGameAgent.Kernel/ProviderTranscript.cs new file mode 100644 index 0000000..ca1bb7f --- /dev/null +++ b/src/OpenGameAgent.Kernel/ProviderTranscript.cs @@ -0,0 +1,197 @@ +using System.Collections.ObjectModel; + +namespace OpenGameAgent.Kernel; + +public delegate string ProviderToolCallIdNormalizer( + string id, + string sourceProvider, + string sourceApi, + string sourceModel); + +/// +/// Produces a provider-safe transcript without mutating the durable session history. +/// Opaque continuity data is retained only for the exact provider, API, and model that issued it. +/// +public static class ProviderTranscript +{ + public static IReadOnlyList Normalize( + IEnumerable messages, + string targetProvider, + string targetApi, + string targetModel, + ProviderToolCallIdNormalizer? normalizeForeignToolCallId = null) + { + if (messages is null) + { + throw new ArgumentNullException(nameof(messages)); + } + + if (string.IsNullOrWhiteSpace(targetProvider) + || string.IsNullOrWhiteSpace(targetApi) + || string.IsNullOrWhiteSpace(targetModel)) + { + throw new ArgumentException("Target provider, API, and model identifiers are required."); + } + + var idMap = new Dictionary(StringComparer.Ordinal); + var transformed = new List(); + foreach (var message in messages) + { + if (message is null) + { + throw new ArgumentException("Provider transcripts cannot contain null messages.", nameof(messages)); + } + + if (message.Role == AgentRole.Tool) + { + var mappedId = idMap.TryGetValue(message.ToolCallId!, out var normalizedId) + ? normalizedId + : message.ToolCallId!; + transformed.Add(CloneToolResult(message, mappedId)); + continue; + } + + if (message.Role != AgentRole.Assistant) + { + transformed.Add(message); + continue; + } + + if (message.StopReason is ModelStopReason.Error or ModelStopReason.Aborted) + { + continue; + } + + var sourceProvider = message.Provider ?? string.Empty; + var sourceApi = message.Api ?? string.Empty; + var sourceModel = message.Model ?? string.Empty; + var sameModel = string.Equals(sourceProvider, targetProvider, StringComparison.Ordinal) + && string.Equals(sourceApi, targetApi, StringComparison.Ordinal) + && string.Equals(sourceModel, targetModel, StringComparison.Ordinal); + var content = new List(); + foreach (var part in message.Content) + { + switch (part) + { + case ReasoningContent reasoning when sameModel: + content.Add(reasoning); + break; + case ReasoningContent reasoning when !reasoning.Redacted && !string.IsNullOrWhiteSpace(reasoning.Text): + content.Add(new TextContent(reasoning.Text)); + break; + case ReasoningContent: + break; + case TextContent text when sameModel: + content.Add(text); + break; + case TextContent text: + content.Add(new TextContent(text.Text)); + break; + case ToolCallContent call when sameModel: + content.Add(call); + break; + case ToolCallContent call: + var mappedId = normalizeForeignToolCallId is null + ? call.Id + : normalizeForeignToolCallId(call.Id, sourceProvider, sourceApi, sourceModel); + if (string.IsNullOrWhiteSpace(mappedId)) + { + throw new InvalidDataException("A provider tool-call ID normalizer returned an empty ID."); + } + + idMap[call.Id] = mappedId; + content.Add(new ToolCallContent(mappedId, call.Name, call.ArgumentsJson)); + break; + default: + content.Add(part); + break; + } + } + + transformed.Add(CloneAssistant(message, content)); + } + + return RepairOrphanedToolCalls(transformed); + } + + private static IReadOnlyList RepairOrphanedToolCalls(IReadOnlyList messages) + { + var result = new List(); + IReadOnlyList pending = Array.Empty(); + var results = new HashSet(StringComparer.Ordinal); + + void FlushMissing(DateTimeOffset timestamp) + { + foreach (var call in pending) + { + if (results.Contains(call.Id)) + { + continue; + } + + result.Add(AgentMessage.ToolResult( + call, + new ToolResult( + new AgentContent[] { new TextContent("No result provided") }, + isError: true), + timestamp)); + } + + pending = Array.Empty(); + results.Clear(); + } + + foreach (var message in messages) + { + if (message.Role == AgentRole.Assistant) + { + FlushMissing(message.Timestamp); + pending = message.Content.OfType().ToArray(); + result.Add(message); + } + else if (message.Role == AgentRole.Tool) + { + results.Add(message.ToolCallId!); + result.Add(message); + } + else + { + FlushMissing(message.Timestamp); + result.Add(message); + } + } + + FlushMissing(messages.Count > 0 ? messages[^1].Timestamp : DateTimeOffset.UtcNow); + return new ReadOnlyCollection(result); + } + + private static AgentMessage CloneToolResult(AgentMessage message, string toolCallId) => new( + AgentRole.Tool, + message.Content, + message.Timestamp, + toolCallId: toolCallId, + toolName: message.ToolName, + isError: message.IsError, + detailsJson: message.DetailsJson, + metadata: message.Metadata, + usage: message.Usage, + addedToolNames: message.AddedToolNames); + + private static AgentMessage CloneAssistant(AgentMessage message, IEnumerable content) => new( + AgentRole.Assistant, + content, + message.Timestamp, + metadata: message.Metadata, + model: message.Model, + stopReason: message.StopReason, + usage: message.Usage, + errorMessage: message.ErrorMessage, + provider: message.Provider, + api: message.Api, + responseModel: message.ResponseModel, + responseId: message.ResponseId, + rawStopReason: message.RawStopReason, + endTurn: message.EndTurn, + diagnostics: message.Diagnostics, + deferred: message.Deferred); +} diff --git a/src/OpenGameAgent.Kernel/StreamingJson.cs b/src/OpenGameAgent.Kernel/StreamingJson.cs new file mode 100644 index 0000000..4bb288a --- /dev/null +++ b/src/OpenGameAgent.Kernel/StreamingJson.cs @@ -0,0 +1,376 @@ +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Kernel; + +/// +/// Produces a valid JSON object from complete or partially streamed tool arguments. +/// +public static class StreamingJson +{ + private const int MaximumDepth = 128; + + public static JsonElement ParseWithRepair(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = MaximumDepth }); + return document.RootElement.Clone(); + } + catch (JsonException) + { + var repaired = Repair(json); + if (string.Equals(repaired, json, StringComparison.Ordinal)) + { + throw; + } + + using var document = JsonDocument.Parse(repaired, new JsonDocumentOptions { MaxDepth = MaximumDepth }); + return document.RootElement.Clone(); + } + } + + public static string ParseObject(string? partialJson) + { + if (string.IsNullOrWhiteSpace(partialJson)) + { + return "{}"; + } + + var repaired = Repair(partialJson); + if (HasExcessiveDepth(repaired)) + { + return "{}"; + } + + if (TryNormalizeObject(repaired, out var normalized)) + { + return normalized; + } + + var completed = CompletePrefix(repaired.AsSpan()); + if (TryNormalizeObject(completed, out normalized)) + { + return normalized; + } + + var safeBoundary = LastBoundaryOutsideString(repaired); + if (safeBoundary >= 0 + && TryNormalizeObject( + CompletePrefix(repaired.AsSpan(0, safeBoundary + 1)), + out normalized)) + { + return normalized; + } + + return "{}"; + } + + public static string Repair(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + var result = new StringBuilder(json.Length); + var inString = false; + for (var index = 0; index < json.Length; index++) + { + var character = json[index]; + if (!inString) + { + result.Append(character); + if (character == '"') + { + inString = true; + } + + continue; + } + + if (character == '"') + { + result.Append(character); + inString = false; + continue; + } + + if (character == '\\') + { + var next = index + 1 < json.Length ? json[index + 1] : '\0'; + if (next == 'u' + && index + 5 < json.Length + && IsHex(json[index + 2]) + && IsHex(json[index + 3]) + && IsHex(json[index + 4]) + && IsHex(json[index + 5])) + { + result.Append(json, index, 6); + index += 5; + continue; + } + + if (next is '"' or '\\' or '/' or 'b' or 'f' or 'n' or 'r' or 't') + { + result.Append(character).Append(next); + index++; + continue; + } + + result.Append("\\\\"); + continue; + } + + if (character <= '\u001f') + { + result.Append(character switch + { + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => "\\u" + ((int)character).ToString("x4"), + }); + continue; + } + + result.Append(character); + } + + return result.ToString(); + } + + private static string CompletePrefix(ReadOnlySpan prefix) + { + var trimmed = prefix.TrimEnd(); + if (trimmed.Length == 0 || trimmed[0] != '{') + { + return string.Empty; + } + + var result = new StringBuilder(trimmed.Length + 16); + var closers = new Stack(); + var inString = false; + var escaped = false; + foreach (var character in trimmed) + { + result.Append(character); + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + switch (character) + { + case '"': + inString = true; + break; + case '{': + if (closers.Count >= MaximumDepth) + { + return string.Empty; + } + + closers.Push('}'); + break; + case '[': + if (closers.Count >= MaximumDepth) + { + return string.Empty; + } + + closers.Push(']'); + break; + case '}': + case ']': + if (closers.Count == 0 || closers.Pop() != character) + { + return string.Empty; + } + + break; + } + } + + if (escaped) + { + result.Append('\\'); + } + + if (inString) + { + result.Append('"'); + } + + var last = LastNonWhitespace(result); + if (last == ':') + { + result.Append("null"); + } + else if (last == ',') + { + RemoveLastNonWhitespace(result); + } + + foreach (var closer in closers) + { + result.Append(closer); + } + + return result.ToString(); + } + + private static bool TryNormalizeObject(string json, out string normalized) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = MaximumDepth }); + if (document.RootElement.ValueKind == JsonValueKind.Object) + { + normalized = document.RootElement.GetRawText(); + return true; + } + } + catch (JsonException) + { + } + + normalized = "{}"; + return false; + } + + private static bool HasExcessiveDepth(string json) + { + var depth = 0; + var inString = false; + var escaped = false; + foreach (var character in json) + { + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + if (character == '"') + { + inString = true; + } + else if (character is '{' or '[') + { + depth++; + if (depth > MaximumDepth) + { + return true; + } + } + else if (character is '}' or ']') + { + depth = Math.Max(0, depth - 1); + } + } + + return false; + } + + private static int LastBoundaryOutsideString(string json) + { + var boundary = -1; + var inString = false; + var escaped = false; + for (var index = 0; index < json.Length; index++) + { + var character = json[index]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + + continue; + } + + if (character == '"') + { + inString = true; + } + else if (character is ':' or ',') + { + boundary = index; + } + } + + return boundary; + } + + private static char LastNonWhitespace(StringBuilder builder) + { + for (var index = builder.Length - 1; index >= 0; index--) + { + if (!char.IsWhiteSpace(builder[index])) + { + return builder[index]; + } + } + + return '\0'; + } + + private static void RemoveLastNonWhitespace(StringBuilder builder) + { + for (var index = builder.Length - 1; index >= 0; index--) + { + if (!char.IsWhiteSpace(builder[index])) + { + builder.Remove(index, 1); + return; + } + } + } + + private static bool IsHex(char value) => + value is >= '0' and <= '9' + or >= 'a' and <= 'f' + or >= 'A' and <= 'F'; +} diff --git a/src/OpenGameAgent.Kernel/Tools.cs b/src/OpenGameAgent.Kernel/Tools.cs index 071cded..da9963a 100644 --- a/src/OpenGameAgent.Kernel/Tools.cs +++ b/src/OpenGameAgent.Kernel/Tools.cs @@ -22,9 +22,68 @@ public enum ToolExecutionMode Parallel, } +public enum ToolConstrainedSamplingKind +{ + JsonSchema, + Grammar, +} + +public enum ToolSchemaStrictness +{ + Prefer, + Require, +} + +public sealed class ToolConstrainedSampling +{ + private ToolConstrainedSampling( + ToolConstrainedSamplingKind kind, + ToolSchemaStrictness? strictness, + string? openAiLark, + string? openAiRegex) + { + Kind = kind; + Strictness = strictness; + OpenAiLark = openAiLark; + OpenAiRegex = openAiRegex; + } + + public ToolConstrainedSamplingKind Kind { get; } + + public ToolSchemaStrictness? Strictness { get; } + + public string? OpenAiLark { get; } + + public string? OpenAiRegex { get; } + + public static ToolConstrainedSampling JsonSchema(ToolSchemaStrictness strictness = ToolSchemaStrictness.Prefer) + { + if (!Enum.IsDefined(typeof(ToolSchemaStrictness), strictness)) + { + throw new ArgumentOutOfRangeException(nameof(strictness)); + } + + return new ToolConstrainedSampling(ToolConstrainedSamplingKind.JsonSchema, strictness, null, null); + } + + public static ToolConstrainedSampling Grammar(string? openAiLark = null, string? openAiRegex = null) + { + if (string.IsNullOrWhiteSpace(openAiLark) && string.IsNullOrWhiteSpace(openAiRegex)) + { + throw new ArgumentException("At least one grammar variant is required."); + } + + return new ToolConstrainedSampling(ToolConstrainedSamplingKind.Grammar, null, openAiLark, openAiRegex); + } +} + public sealed class ToolDefinition { - public ToolDefinition(string name, string description, string inputSchemaJson) + public ToolDefinition( + string name, + string description, + string inputSchemaJson, + ToolConstrainedSampling? constrainedSampling = null) { if (string.IsNullOrWhiteSpace(name)) { @@ -39,6 +98,7 @@ public ToolDefinition(string name, string description, string inputSchemaJson) Name = name; Description = description; InputSchemaJson = JsonValue.RequireObject(inputSchemaJson, nameof(inputSchemaJson)); + ConstrainedSampling = constrainedSampling; } public string Name { get; } @@ -46,11 +106,17 @@ public ToolDefinition(string name, string description, string inputSchemaJson) public string Description { get; } public string InputSchemaJson { get; } + + public ToolConstrainedSampling? ConstrainedSampling { get; } } public sealed class ToolProgress { - public ToolProgress(string? message = null, double? fraction = null, string? detailsJson = null) + public ToolProgress( + string? message = null, + double? fraction = null, + string? detailsJson = null, + IEnumerable? content = null) { if (fraction is { } value && (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > 1)) @@ -61,6 +127,20 @@ public ToolProgress(string? message = null, double? fraction = null, string? det Message = message; Fraction = fraction; DetailsJson = detailsJson is null ? null : JsonValue.RequireValid(detailsJson, nameof(detailsJson)); + var copied = content?.ToArray() ?? Array.Empty(); + if (copied.Any(part => part is null)) + { + throw new ArgumentException("Tool progress content cannot contain null parts.", nameof(content)); + } + + if (copied.Any(part => part is ReasoningContent or ToolCallContent)) + { + throw new ArgumentException( + "Tool progress cannot contain assistant-only reasoning or tool-call parts.", + nameof(content)); + } + + Content = Array.AsReadOnly(copied); } public string? Message { get; } @@ -68,6 +148,8 @@ public ToolProgress(string? message = null, double? fraction = null, string? det public double? Fraction { get; } public string? DetailsJson { get; } + + public IReadOnlyList Content { get; } } public sealed class ToolResult @@ -78,7 +160,8 @@ public ToolResult( string? detailsJson = null, bool terminate = false, ModelUsage? usage = null, - bool outcomeUncertain = false) + bool outcomeUncertain = false, + IEnumerable? addedToolNames = null) { var copied = content?.ToArray() ?? throw new ArgumentNullException(nameof(content)); if (copied.Any(part => part is null)) @@ -99,6 +182,14 @@ public ToolResult( Terminate = terminate; Usage = usage; OutcomeUncertain = outcomeUncertain; + var copiedAddedTools = addedToolNames?.ToArray() ?? Array.Empty(); + if (copiedAddedTools.Any(string.IsNullOrWhiteSpace) + || copiedAddedTools.Distinct(StringComparer.Ordinal).Count() != copiedAddedTools.Length) + { + throw new ArgumentException("Added tool names must be non-empty and unique.", nameof(addedToolNames)); + } + + AddedToolNames = Array.AsReadOnly(copiedAddedTools); } public IReadOnlyList Content { get; } @@ -113,6 +204,8 @@ public ToolResult( public bool OutcomeUncertain { get; } + public IReadOnlyList AddedToolNames { get; } + public static ToolResult Error(string message) => new(new AgentContent[] { new TextContent(message ?? string.Empty) }, isError: true); } diff --git a/src/OpenGameAgent.Media/GameMediaModelRegistry.cs b/src/OpenGameAgent.Media/GameMediaModelRegistry.cs new file mode 100644 index 0000000..ff7c472 --- /dev/null +++ b/src/OpenGameAgent.Media/GameMediaModelRegistry.cs @@ -0,0 +1,1358 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Models; + +namespace OpenGameAgent.Media; + +public enum GameMediaModelGenerationStatus +{ + Completed, + Failed, + Canceled, +} + +public sealed class GameMediaModelGenerationResult +{ + internal GameMediaModelGenerationResult( + string providerId, + string modelId, + GameMediaKind kind, + GameMediaModelGenerationStatus status, + GameMediaGenerationResult? result, + string? errorCode, + string? errorMessage) + { + ProviderId = providerId; + ModelId = modelId; + Kind = kind; + Status = status; + Result = result; + ErrorCode = errorCode; + ErrorMessage = errorMessage; + } + + public string ProviderId { get; } + + public string ModelId { get; } + + public GameMediaKind Kind { get; } + + public GameMediaModelGenerationStatus Status { get; } + + public GameMediaGenerationResult? Result { get; } + + public string? ErrorCode { get; } + + public string? ErrorMessage { get; } +} + +public enum GameMediaModelRefreshStatus +{ + Updated, + Unchanged, + SkippedStatic, + SkippedUnconfigured, + StaleRegistration, + Failed, + Canceled, +} + +public sealed class GameMediaModelRefreshResult +{ + internal GameMediaModelRefreshResult( + string providerId, + GameMediaModelRefreshStatus status, + int modelCount, + string? errorMessage = null) + { + ProviderId = providerId; + Status = status; + ModelCount = modelCount; + ErrorMessage = errorMessage; + } + + public string ProviderId { get; } + + public GameMediaModelRefreshStatus Status { get; } + + public int ModelCount { get; } + + public string? ErrorMessage { get; } +} + +public sealed class GameMediaModelRegistryOptions +{ + public int MaxProviders { get; set; } = 128; + + public int MaxModelsPerProvider { get; set; } = 100_000; + + public int MaxSources { get; set; } = 128; + + public int MaxOutputs { get; set; } = 32; + + public int MaxPromptBytes { get; set; } = 1_000_000; + + public int MaxJsonBytes { get; set; } = 1_000_000; + + public int MaxResourceUriBytes { get; set; } = 8_000_000; + + public int MaxResourceNameBytes { get; set; } = 16_384; + + public int MaxAggregateResourceBytes { get; set; } = 16_000_000; + + public int MaxProgressEvents { get; set; } = 10_000; + + public int MaxErrorCharacters { get; set; } = 65_536; + + public TimeSpan GenerationTimeout { get; set; } = TimeSpan.FromMinutes(10); + + public TimeSpan RefreshTimeout { get; set; } = TimeSpan.FromMinutes(2); + + public TimeSpan ProgressCallbackTimeout { get; set; } = TimeSpan.FromSeconds(30); +} + +public sealed class GameMediaModelRefreshContext +{ + internal GameMediaModelRefreshContext( + GameProviderDescriptor provider, + IReadOnlyList currentModels, + GameProviderAuthResolution? authentication) + { + Provider = provider; + CurrentModels = currentModels; + Authentication = authentication; + } + + public GameProviderDescriptor Provider { get; } + + public IReadOnlyList CurrentModels { get; } + + public GameProviderAuthResolution? Authentication { get; } +} + +public sealed class GameMediaGenerationInvocation +{ + internal GameMediaGenerationInvocation( + GameProviderDescriptor provider, + GameModelDescriptor model, + GameProviderAuthResolution? authentication, + GameMediaGenerationRequest request) + { + Provider = provider; + Model = model; + Authentication = authentication; + Request = request; + Endpoint = authentication?.BaseUrl ?? model.BaseUrl ?? provider.Endpoint; + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in model.Headers) + { + if (pair.Value is not null) + { + headers[pair.Key] = pair.Value; + } + } + + foreach (var pair in authentication?.Headers ?? new Dictionary()) + { + if (pair.Value is null) + { + headers.Remove(pair.Key); + } + else + { + headers[pair.Key] = pair.Value; + } + } + + Headers = new ReadOnlyDictionary(headers); + Configuration = authentication?.Configuration + ?? new ReadOnlyDictionary(new Dictionary()); + } + + public GameProviderDescriptor Provider { get; } + + public GameModelDescriptor Model { get; } + + public GameProviderAuthResolution? Authentication { get; } + + public GameMediaGenerationRequest Request { get; } + + public Uri? Endpoint { get; } + + public IReadOnlyDictionary Headers { get; } + + public IReadOnlyDictionary Configuration { get; } +} + +public delegate ValueTask> GameMediaModelRefresh( + GameMediaModelRefreshContext context, + CancellationToken cancellationToken); + +public delegate IGameMediaGenerator GameMediaGeneratorFactory(GameMediaGenerationInvocation invocation); + +public sealed class GameMediaProviderRegistration +{ + public GameMediaProviderRegistration( + GameProviderDescriptor descriptor, + IGameProviderAuthentication authentication, + GameMediaGeneratorFactory generatorFactory, + IReadOnlyList? models = null, + GameMediaModelRefresh? refreshModels = null) + { + Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + Authentication = authentication ?? throw new ArgumentNullException(nameof(authentication)); + GeneratorFactory = generatorFactory ?? throw new ArgumentNullException(nameof(generatorFactory)); + if (refreshModels is not null && !descriptor.SupportsDynamicModels) + { + throw new ArgumentException( + "A provider with model refresh must declare dynamic model support.", + nameof(refreshModels)); + } + + Models = ValidateModels(descriptor.ProviderId, models ?? Array.Empty(), 100_000); + RefreshModels = refreshModels; + } + + public GameProviderDescriptor Descriptor { get; } + + public IGameProviderAuthentication Authentication { get; } + + public GameMediaGeneratorFactory GeneratorFactory { get; } + + public IReadOnlyList Models { get; } + + public GameMediaModelRefresh? RefreshModels { get; } + + internal static IReadOnlyList ValidateModels( + string providerId, + IReadOnlyList models, + int maximum) + { + if (models is null) + { + throw new ArgumentNullException(nameof(models)); + } + + var copy = models.ToArray(); + if (copy.Length > maximum) + { + throw new ArgumentException("The media provider exposes too many models.", nameof(models)); + } + + if (copy.Any(model => model is null)) + { + throw new ArgumentException("A media model catalog cannot contain null entries.", nameof(models)); + } + + if (copy.Any(model => !string.Equals(model.ProviderId, providerId, StringComparison.Ordinal))) + { + throw new ArgumentException("Every media model must belong to its registered provider.", nameof(models)); + } + + if (copy.Any(model => (model.OutputCapabilities & MediaOutputCapabilities) == 0)) + { + throw new ArgumentException("Every media model must declare image, audio, or video output.", nameof(models)); + } + + var duplicate = copy.GroupBy(model => model.ModelId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException("Media model IDs must be unique within a provider.", nameof(models)); + } + + return Array.AsReadOnly(copy); + } + + private const GameModelOutputCapabilities MediaOutputCapabilities = + GameModelOutputCapabilities.Image | + GameModelOutputCapabilities.Audio | + GameModelOutputCapabilities.Video; +} + +public sealed class GameMediaModelRegistry : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _providers = new(StringComparer.Ordinal); + private readonly int _maxProviders; + private readonly int _maxModelsPerProvider; + private readonly int _maxSources; + private readonly int _maxOutputs; + private readonly int _maxPromptBytes; + private readonly int _maxJsonBytes; + private readonly int _maxResourceUriBytes; + private readonly int _maxResourceNameBytes; + private readonly int _maxAggregateResourceBytes; + private readonly int _maxProgressEvents; + private readonly int _maxErrorCharacters; + private readonly TimeSpan _generationTimeout; + private readonly TimeSpan _refreshTimeout; + private readonly TimeSpan _progressCallbackTimeout; + private bool _disposed; + + public GameMediaModelRegistry(GameMediaModelRegistryOptions? options = null) + { + options ??= new GameMediaModelRegistryOptions(); + _maxProviders = RequireRange(options.MaxProviders, 1, 100_000, nameof(options.MaxProviders)); + _maxModelsPerProvider = RequireRange( + options.MaxModelsPerProvider, + 1, + 100_000, + nameof(options.MaxModelsPerProvider)); + _maxSources = RequireRange(options.MaxSources, 0, 10_000, nameof(options.MaxSources)); + _maxOutputs = RequireRange(options.MaxOutputs, 1, 10_000, nameof(options.MaxOutputs)); + _maxPromptBytes = RequireRange(options.MaxPromptBytes, 1, 100_000_000, nameof(options.MaxPromptBytes)); + _maxJsonBytes = RequireRange(options.MaxJsonBytes, 2, 100_000_000, nameof(options.MaxJsonBytes)); + _maxResourceUriBytes = RequireRange( + options.MaxResourceUriBytes, + 1, + 100_000_000, + nameof(options.MaxResourceUriBytes)); + _maxResourceNameBytes = RequireRange( + options.MaxResourceNameBytes, + 1, + 1_000_000, + nameof(options.MaxResourceNameBytes)); + _maxAggregateResourceBytes = RequireRange( + options.MaxAggregateResourceBytes, + 1, + 200_000_000, + nameof(options.MaxAggregateResourceBytes)); + _maxProgressEvents = RequireRange( + options.MaxProgressEvents, + 0, + 1_000_000, + nameof(options.MaxProgressEvents)); + _maxErrorCharacters = RequireRange( + options.MaxErrorCharacters, + 1, + 65_536, + nameof(options.MaxErrorCharacters)); + _generationTimeout = RequireDuration( + options.GenerationTimeout, + TimeSpan.FromMilliseconds(100), + TimeSpan.FromHours(24), + nameof(options.GenerationTimeout)); + _refreshTimeout = RequireDuration( + options.RefreshTimeout, + TimeSpan.FromMilliseconds(100), + TimeSpan.FromHours(1), + nameof(options.RefreshTimeout)); + _progressCallbackTimeout = RequireDuration( + options.ProgressCallbackTimeout, + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMinutes(5), + nameof(options.ProgressCallbackTimeout)); + } + + public void Register(GameMediaProviderRegistration registration, bool replace = false) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + _ = GameMediaProviderRegistration.ValidateModels( + registration.Descriptor.ProviderId, + registration.Models, + _maxModelsPerProvider); + Entry? replaced = null; + lock (_gate) + { + ThrowIfDisposed(); + var id = registration.Descriptor.ProviderId; + if (_providers.TryGetValue(id, out var current) && !replace) + { + throw new InvalidOperationException($"Media provider '{id}' is already registered."); + } + + if (current is null && _providers.Count >= _maxProviders) + { + throw new InvalidOperationException("The media provider registry reached its capacity."); + } + + replaced = current; + _providers[id] = new Entry(registration); + } + + replaced?.Cancel(); + } + + public bool Unregister(string providerId) + { + var id = RequireId(providerId, nameof(providerId)); + Entry? removed; + lock (_gate) + { + ThrowIfDisposed(); + if (!_providers.Remove(id, out removed)) + { + return false; + } + } + + removed.Cancel(); + return true; + } + + public IReadOnlyList GetProviders() + { + lock (_gate) + { + ThrowIfDisposed(); + return Array.AsReadOnly(_providers.Values + .OrderBy(entry => entry.Registration.Descriptor.ProviderId, StringComparer.Ordinal) + .Select(entry => entry.Registration) + .ToArray()); + } + } + + public GameMediaProviderRegistration? GetProvider(string providerId) + { + var id = RequireId(providerId, nameof(providerId)); + lock (_gate) + { + ThrowIfDisposed(); + return _providers.TryGetValue(id, out var entry) ? entry.Registration : null; + } + } + + public IReadOnlyList GetModels(string? providerId = null) + { + lock (_gate) + { + ThrowIfDisposed(); + if (providerId is not null) + { + var id = RequireId(providerId, nameof(providerId)); + return _providers.TryGetValue(id, out var entry) + ? Array.AsReadOnly(entry.CurrentModels.ToArray()) + : Array.Empty(); + } + + return Array.AsReadOnly(_providers.Values + .OrderBy(entry => entry.Registration.Descriptor.ProviderId, StringComparer.Ordinal) + .SelectMany(entry => entry.CurrentModels) + .ToArray()); + } + } + + public GameModelDescriptor? GetModel(string providerId, string modelId) + { + var provider = RequireId(providerId, nameof(providerId)); + var model = RequireId(modelId, nameof(modelId)); + lock (_gate) + { + ThrowIfDisposed(); + return _providers.TryGetValue(provider, out var entry) + ? entry.CurrentModels.FirstOrDefault(candidate => + string.Equals(candidate.ModelId, model, StringComparison.Ordinal)) + : null; + } + } + + public ValueTask RefreshAsync( + string providerId, + CancellationToken cancellationToken = default) + { + var safeId = SafeId(providerId); + if (!TryRequireId(providerId, out var id)) + { + return new ValueTask(new GameMediaModelRefreshResult( + safeId, + GameMediaModelRefreshStatus.Failed, + 0, + "A valid media provider ID is required.")); + } + + Entry? entry; + lock (_gate) + { + if (_disposed) + { + return new ValueTask(new GameMediaModelRefreshResult( + id, + GameMediaModelRefreshStatus.Failed, + 0, + "The media model registry is disposed.")); + } + + if (!_providers.TryGetValue(id, out entry)) + { + return new ValueTask(new GameMediaModelRefreshResult( + id, + GameMediaModelRefreshStatus.Failed, + 0, + "The media provider is not registered.")); + } + + if (entry.Registration.RefreshModels is null) + { + return new ValueTask(new GameMediaModelRefreshResult( + id, + GameMediaModelRefreshStatus.SkippedStatic, + entry.CurrentModels.Count)); + } + } + + var refresh = entry.GetOrStartRefresh(this); + return new ValueTask(ObserveRefreshAsync(entry, refresh, cancellationToken)); + } + + public async ValueTask> RefreshAsync( + IReadOnlyCollection? providerIds = null, + CancellationToken cancellationToken = default) + { + string[] ids; + lock (_gate) + { + ThrowIfDisposed(); + ids = (providerIds ?? _providers.Keys.ToArray()) + .Where(id => TryRequireId(id, out _)) + .Distinct(StringComparer.Ordinal) + .Where(id => _providers.ContainsKey(id)) + .OrderBy(id => id, StringComparer.Ordinal) + .Take(_maxProviders) + .ToArray(); + } + + var tasks = ids.Select(id => RefreshAsync(id, cancellationToken).AsTask()).ToArray(); + return Array.AsReadOnly(await Task.WhenAll(tasks).ConfigureAwait(false)); + } + + public async ValueTask GenerateAsync( + string providerId, + string modelId, + GameMediaGenerationRequest request, + GameMediaProgressHandler? progress = null, + CancellationToken cancellationToken = default) + { + var safeProvider = SafeId(providerId); + var safeModel = SafeId(modelId); + var kind = request?.Kind ?? GameMediaKind.Image; + if (!TryRequireId(providerId, out var provider) || !TryRequireId(modelId, out var model)) + { + return Failed(safeProvider, safeModel, kind, "invalid_request", "Valid provider and model IDs are required."); + } + + if (request is null) + { + return Failed(provider, model, kind, "invalid_request", "A media generation request is required."); + } + + if (cancellationToken.IsCancellationRequested) + { + return Canceled(provider, model, kind); + } + + Entry? entry; + GameModelDescriptor? descriptor; + lock (_gate) + { + if (_disposed) + { + return Failed(provider, model, kind, "registry_disposed", "The media model registry is disposed."); + } + + if (!_providers.TryGetValue(provider, out entry)) + { + return Failed(provider, model, kind, "provider_not_found", "The media provider is not registered."); + } + + descriptor = entry.CurrentModels.FirstOrDefault(candidate => + string.Equals(candidate.ModelId, model, StringComparison.Ordinal)); + if (descriptor is null) + { + return Failed(provider, model, kind, "model_not_found", "The media model is not registered."); + } + } + + var capabilityError = ValidateCapabilities(descriptor, request); + if (capabilityError is not null) + { + return Failed(provider, model, kind, "capability_mismatch", capabilityError); + } + + var requestError = ValidateRequest(request); + if (requestError is not null) + { + return Failed(provider, model, kind, "request_limit", requestError); + } + + using var operation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + entry.LifetimeToken); + operation.CancelAfter(_generationTimeout); + try + { + var authStatus = await AwaitWithCancellation( + entry.Registration.Authentication.CheckAsync(operation.Token).AsTask(), + operation.Token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The authentication provider returned no status."); + if (!authStatus.Configured) + { + return Failed( + provider, + model, + kind, + "authentication_unconfigured", + authStatus.Error ?? "The media provider is not configured."); + } + + var authentication = await AwaitWithCancellation( + entry.Registration.Authentication.ResolveAsync(operation.Token).AsTask(), + operation.Token).ConfigureAwait(false); + var invocation = new GameMediaGenerationInvocation( + entry.Registration.Descriptor, + descriptor, + authentication, + request); + var generator = entry.Registration.GeneratorFactory(invocation) + ?? throw new InvalidOperationException("The media generator factory returned no generator."); + var progressCount = 0; + long progressResourceBytes = 0; + var result = await AwaitWithCancellation( + generator.GenerateAsync( + request, + progress is null + ? null + : async (update, _) => + { + if (update is null) + { + throw new InvalidOperationException("The media generator reported null progress."); + } + + if (Interlocked.Increment(ref progressCount) > _maxProgressEvents) + { + throw new InvalidOperationException("The media generator exceeded the progress event limit."); + } + + if (update.DetailsJson is { } details + && Encoding.UTF8.GetByteCount(details) > _maxJsonBytes) + { + throw new InvalidOperationException("Media progress details exceeded the JSON size limit."); + } + + if (update.Preview is { } preview) + { + if (MediaKind(preview.MediaType) != request.Kind) + { + throw new InvalidOperationException( + "Media progress returned a preview of the wrong media kind."); + } + + var uriBytes = Encoding.UTF8.GetByteCount(preview.Uri); + var mediaTypeBytes = Encoding.UTF8.GetByteCount(preview.MediaType); + var nameBytes = preview.Name is null + ? 0 + : Encoding.UTF8.GetByteCount(preview.Name); + if (ContainsForbiddenResourceCharacter(preview.Uri) + || ContainsForbiddenResourceCharacter(preview.MediaType) + || ContainsForbiddenResourceCharacter(preview.Name) + || uriBytes > _maxResourceUriBytes + || mediaTypeBytes > 512 + || nameBytes > _maxResourceNameBytes) + { + throw new InvalidOperationException( + "A media progress preview exceeded its size limit."); + } + + var resourceBytes = checked((long)uriBytes + mediaTypeBytes + nameBytes); + if (Interlocked.Add(ref progressResourceBytes, resourceBytes) + > _maxAggregateResourceBytes) + { + throw new InvalidOperationException( + "Media progress previews exceeded their aggregate size limit."); + } + } + + using var callback = CancellationTokenSource.CreateLinkedTokenSource(operation.Token); + callback.CancelAfter(_progressCallbackTimeout); + try + { + await AwaitWithCancellation( + progress(update, callback.Token).AsTask(), + callback.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!operation.IsCancellationRequested) + { + throw new TimeoutException("The media progress callback timed out."); + } + }, + operation.Token).AsTask(), + operation.Token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The media generator returned no result."); + var resultError = ValidateResult(request.Kind, result); + if (resultError is not null) + { + return Failed(provider, model, kind, "invalid_result", resultError); + } + + return new GameMediaModelGenerationResult( + provider, + model, + kind, + GameMediaModelGenerationStatus.Completed, + result, + null, + null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || entry.LifetimeToken.IsCancellationRequested) + { + return Canceled(provider, model, kind); + } + catch (OperationCanceledException) when (operation.IsCancellationRequested) + { + return Failed(provider, model, kind, "timeout", "The media generation operation timed out."); + } + catch (Exception exception) + { + return Failed(provider, model, kind, "generation_failed", exception.Message); + } + } + + public void Dispose() + { + Entry[] entries; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + entries = _providers.Values.ToArray(); + _providers.Clear(); + } + + foreach (var entry in entries) + { + entry.Cancel(); + } + } + + private async Task RunRefreshAsync( + Entry entry) + { + using var operation = CancellationTokenSource.CreateLinkedTokenSource(entry.LifetimeToken); + operation.CancelAfter(_refreshTimeout); + var token = operation.Token; + var providerId = entry.Registration.Descriptor.ProviderId; + try + { + IReadOnlyList currentModels; + lock (_gate) + { + if (_disposed || !_providers.TryGetValue(providerId, out var current) || !ReferenceEquals(current, entry)) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.StaleRegistration, + entry.CurrentModels.Count); + } + + currentModels = Array.AsReadOnly(entry.CurrentModels.ToArray()); + } + + var authStatus = await AwaitWithCancellation( + entry.Registration.Authentication.CheckAsync(token).AsTask(), + token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The authentication provider returned no status."); + if (!authStatus.Configured) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.SkippedUnconfigured, + currentModels.Count, + Bound(authStatus.Error)); + } + + var authentication = await AwaitWithCancellation( + entry.Registration.Authentication.ResolveAsync(token).AsTask(), + token).ConfigureAwait(false); + var refreshed = await AwaitWithCancellation( + entry.Registration.RefreshModels!( + new GameMediaModelRefreshContext( + entry.Registration.Descriptor, + currentModels, + authentication), + token).AsTask(), + token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The media model refresh returned no models."); + var validated = GameMediaProviderRegistration.ValidateModels( + providerId, + refreshed, + _maxModelsPerProvider); + token.ThrowIfCancellationRequested(); + lock (_gate) + { + if (_disposed || !_providers.TryGetValue(providerId, out var current) || !ReferenceEquals(current, entry)) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.StaleRegistration, + currentModels.Count); + } + + var changed = !ModelsEquivalent(entry.CurrentModels, validated); + entry.CurrentModels = validated; + return new GameMediaModelRefreshResult( + providerId, + changed ? GameMediaModelRefreshStatus.Updated : GameMediaModelRefreshStatus.Unchanged, + validated.Count); + } + } + catch (OperationCanceledException) when (entry.LifetimeToken.IsCancellationRequested) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.StaleRegistration, + entry.CurrentModels.Count); + } + catch (OperationCanceledException) when (operation.IsCancellationRequested) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.Failed, + entry.CurrentModels.Count, + Bound("The media model refresh timed out.")); + } + catch (OperationCanceledException exception) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.Failed, + entry.CurrentModels.Count, + Bound(exception.Message)); + } + catch (Exception exception) + { + return new GameMediaModelRefreshResult( + providerId, + GameMediaModelRefreshStatus.Failed, + entry.CurrentModels.Count, + Bound(exception.Message)); + } + } + + private async Task ObserveRefreshAsync( + Entry entry, + Task refresh, + CancellationToken cancellationToken) + { + try + { + return await AwaitWithCancellation(refresh, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return new GameMediaModelRefreshResult( + entry.Registration.Descriptor.ProviderId, + GameMediaModelRefreshStatus.Canceled, + entry.CurrentModels.Count); + } + } + + private string? ValidateRequest(GameMediaGenerationRequest request) + { + try + { + if (request.Sources.Count > _maxSources) + { + return "The media generation request contains too many sources."; + } + + if (request.Prompt is { } prompt && Encoding.UTF8.GetByteCount(prompt) > _maxPromptBytes) + { + return "The media generation prompt exceeded the size limit."; + } + + if (!ValidateJson(request.ContextJson) || !ValidateJson(request.ParametersJson)) + { + return "Media generation JSON contains duplicate property names or exceeds its size limit."; + } + + long aggregate = 0; + foreach (var source in request.Sources) + { + var uriBytes = Encoding.UTF8.GetByteCount(source.Uri); + var mediaTypeBytes = Encoding.UTF8.GetByteCount(source.MediaType); + var nameBytes = source.Name is null ? 0 : Encoding.UTF8.GetByteCount(source.Name); + if (ContainsForbiddenResourceCharacter(source.Uri) + || ContainsForbiddenResourceCharacter(source.MediaType) + || ContainsForbiddenResourceCharacter(source.Name) + || uriBytes > _maxResourceUriBytes + || mediaTypeBytes > 512 + || nameBytes > _maxResourceNameBytes) + { + return "A media source exceeded its size limit."; + } + + aggregate = checked(aggregate + uriBytes + mediaTypeBytes + nameBytes); + if (aggregate > _maxAggregateResourceBytes) + { + return "The media sources exceeded their aggregate size limit."; + } + } + + return null; + } + catch (OverflowException) + { + return "The media generation request exceeded its aggregate size limit."; + } + } + + private bool ValidateJson(string value) + { + if (Encoding.UTF8.GetByteCount(value) > _maxJsonBytes) + { + return false; + } + + using var document = JsonDocument.Parse(value); + return HasUniqueProperties(document.RootElement); + } + + private static bool HasUniqueProperties(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name) || !HasUniqueProperties(property.Value)) + { + return false; + } + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + if (!HasUniqueProperties(item)) + { + return false; + } + } + } + + return true; + } + + private string? ValidateResult(GameMediaKind kind, GameMediaGenerationResult result) + { + if (result.Outputs.Count == 0 || result.Outputs.Count > _maxOutputs) + { + return "The media generator returned an invalid number of outputs."; + } + + if (Encoding.UTF8.GetByteCount(result.MetadataJson) > _maxJsonBytes || !ValidateJson(result.MetadataJson)) + { + return "The media generator returned invalid or oversized metadata."; + } + + long aggregate = 0; + try + { + foreach (var output in result.Outputs) + { + if (MediaKind(output.MediaType) != kind) + { + return "The media generator returned an output of the wrong media kind."; + } + + var uriBytes = Encoding.UTF8.GetByteCount(output.Uri); + var mediaTypeBytes = Encoding.UTF8.GetByteCount(output.MediaType); + var nameBytes = output.Name is null ? 0 : Encoding.UTF8.GetByteCount(output.Name); + if (ContainsForbiddenResourceCharacter(output.Uri) + || ContainsForbiddenResourceCharacter(output.MediaType) + || ContainsForbiddenResourceCharacter(output.Name) + || uriBytes > _maxResourceUriBytes + || mediaTypeBytes > 512 + || nameBytes > _maxResourceNameBytes) + { + return "A media output exceeded its size limit."; + } + + aggregate = checked(aggregate + uriBytes + mediaTypeBytes + nameBytes); + if (aggregate > _maxAggregateResourceBytes) + { + return "The media outputs exceeded their aggregate size limit."; + } + } + } + catch (OverflowException) + { + return "The media outputs exceeded their aggregate size limit."; + } + + return null; + } + + private static bool ContainsForbiddenResourceCharacter(string? value) => + value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0; + + private static string? ValidateCapabilities( + GameModelDescriptor model, + GameMediaGenerationRequest request) + { + var requiredOutput = request.Kind switch + { + GameMediaKind.Image => GameModelOutputCapabilities.Image, + GameMediaKind.Audio => GameModelOutputCapabilities.Audio, + GameMediaKind.Video => GameModelOutputCapabilities.Video, + _ => GameModelOutputCapabilities.None, + }; + if ((model.OutputCapabilities & requiredOutput) != requiredOutput) + { + return "The selected model cannot generate the requested media kind."; + } + + if (!string.IsNullOrEmpty(request.Prompt) + && !model.InputCapabilities.HasFlag(GameModelInputCapabilities.Text)) + { + return "The selected model does not accept text prompts."; + } + + foreach (var source in request.Sources) + { + var requiredInput = MediaKind(source.MediaType) switch + { + GameMediaKind.Image => GameModelInputCapabilities.Image, + GameMediaKind.Audio => GameModelInputCapabilities.Audio, + GameMediaKind.Video => GameModelInputCapabilities.Video, + _ => GameModelInputCapabilities.None, + }; + if (requiredInput == GameModelInputCapabilities.None) + { + return "A supplied source has an unsupported media type."; + } + + if (!model.InputCapabilities.HasFlag(requiredInput)) + { + return "The selected model does not accept one of the supplied media kinds."; + } + } + + return null; + } + + private GameMediaModelGenerationResult Failed( + string providerId, + string modelId, + GameMediaKind kind, + string code, + string message) => + new( + providerId, + modelId, + kind, + GameMediaModelGenerationStatus.Failed, + null, + code, + Bound(message) ?? "Media generation failed."); + + private GameMediaModelGenerationResult Canceled( + string providerId, + string modelId, + GameMediaKind kind) => + new( + providerId, + modelId, + kind, + GameMediaModelGenerationStatus.Canceled, + null, + "canceled", + Bound("The media generation operation was canceled.")); + + private string? Bound(string? value) + { + if (value is null) + { + return null; + } + + return value.Length <= _maxErrorCharacters ? value : value.Substring(0, _maxErrorCharacters); + } + + private static GameMediaKind? MediaKind(string mediaType) + { + if (mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return GameMediaKind.Image; + } + + if (mediaType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return GameMediaKind.Audio; + } + + if (mediaType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + { + return GameMediaKind.Video; + } + + return null; + } + + private static async Task AwaitWithCancellation(Task task, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (task.IsCompleted) + { + return await task.ConfigureAwait(false); + } + + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + state => ((TaskCompletionSource)state!).TrySetResult(true), + cancellation); + if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false)) + { + ObserveLateFault(task); + throw new OperationCanceledException(cancellationToken); + } + + return await task.ConfigureAwait(false); + } + + private static async Task AwaitWithCancellation(Task task, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (task.IsCompleted) + { + await task.ConfigureAwait(false); + return; + } + + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + state => ((TaskCompletionSource)state!).TrySetResult(true), + cancellation); + if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false)) + { + ObserveLateFault(task); + throw new OperationCanceledException(cancellationToken); + } + + await task.ConfigureAwait(false); + } + + private static void ObserveLateFault(Task task) + { + _ = task.ContinueWith( + completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private static bool ModelsEquivalent( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + var first = left[index]; + var second = right[index]; + if (!string.Equals(first.ProviderId, second.ProviderId, StringComparison.Ordinal) + || !string.Equals(first.ModelId, second.ModelId, StringComparison.Ordinal) + || !string.Equals(first.DisplayName, second.DisplayName, StringComparison.Ordinal) + || !string.Equals(first.Api, second.Api, StringComparison.Ordinal) + || !Equals(first.BaseUrl, second.BaseUrl) + || first.ContextWindowTokens != second.ContextWindowTokens + || first.MaximumOutputTokens != second.MaximumOutputTokens + || first.InputCapabilities != second.InputCapabilities + || first.OutputCapabilities != second.OutputCapabilities + || !first.ReasoningLevels.SequenceEqual(second.ReasoningLevels) + || !ReasoningValuesEquivalent(first.ReasoningLevelValues, second.ReasoningLevelValues) + || !CostEquivalent(first.Cost, second.Cost) + || !DictionaryEquivalent(first.Metadata, second.Metadata) + || !NullableDictionaryEquivalent(first.Headers, second.Headers) + || !string.Equals(first.SamplingParametersJson, second.SamplingParametersJson, StringComparison.Ordinal) + || !string.Equals(first.CompatibilityJson, second.CompatibilityJson, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool ReasoningValuesEquivalent( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private static bool CostEquivalent(GameModelCost left, GameModelCost right) => + left.InputPerMillionTokens == right.InputPerMillionTokens + && left.OutputPerMillionTokens == right.OutputPerMillionTokens + && left.CacheReadPerMillionTokens == right.CacheReadPerMillionTokens + && left.CacheWritePerMillionTokens == right.CacheWritePerMillionTokens + && left.Tiers.Count == right.Tiers.Count + && left.Tiers.Zip(right.Tiers, CostTierEquivalent).All(equivalent => equivalent); + + private static bool CostTierEquivalent(GameModelCostTier left, GameModelCostTier right) => + left.InputTokensAbove == right.InputTokensAbove + && left.InputPerMillionTokens == right.InputPerMillionTokens + && left.OutputPerMillionTokens == right.OutputPerMillionTokens + && left.CacheReadPerMillionTokens == right.CacheReadPerMillionTokens + && left.CacheWritePerMillionTokens == right.CacheWritePerMillionTokens; + + private static bool DictionaryEquivalent( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private static bool NullableDictionaryEquivalent( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private static int RequireRange(int value, int minimum, int maximum, string parameterName) => + value >= minimum && value <= maximum + ? value + : throw new ArgumentOutOfRangeException(parameterName); + + private static TimeSpan RequireDuration( + TimeSpan value, + TimeSpan minimum, + TimeSpan maximum, + string parameterName) => + value >= minimum && value <= maximum + ? value + : throw new ArgumentOutOfRangeException(parameterName); + + private static string RequireId(string? value, string parameterName) => + TryRequireId(value, out var valid) + ? valid + : throw new ArgumentException("A non-empty identifier of at most 512 characters is required.", parameterName); + + private static bool TryRequireId(string? value, out string valid) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512) + { + valid = string.Empty; + return false; + } + + valid = value; + return true; + } + + private static string SafeId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "invalid"; + } + + return value.Length <= 512 ? value : value.Substring(0, 512); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(GameMediaModelRegistry)); + } + } + + private sealed class Entry + { + private readonly object _refreshGate = new(); + private readonly CancellationTokenSource _lifetime = new(); + private readonly CancellationToken _lifetimeToken; + private Task? _inflightRefresh; + private int _canceled; + + public Entry(GameMediaProviderRegistration registration) + { + Registration = registration; + CurrentModels = registration.Models; + _lifetimeToken = _lifetime.Token; + } + + public GameMediaProviderRegistration Registration { get; } + + public IReadOnlyList CurrentModels { get; set; } + + public CancellationToken LifetimeToken => _lifetimeToken; + + public Task GetOrStartRefresh( + GameMediaModelRegistry owner) + { + lock (_refreshGate) + { + if (_inflightRefresh is not null) + { + return _inflightRefresh; + } + + var refresh = owner.RunRefreshAsync(this); + _inflightRefresh = refresh; + _ = refresh.ContinueWith( + (_, state) => ((Entry)state!).ClearRefresh(refresh), + this, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return refresh; + } + } + + public void Cancel() + { + if (Interlocked.Exchange(ref _canceled, 1) != 0) + { + return; + } + + try + { + _lifetime.Cancel(); + } + catch (AggregateException) + { + // A provider callback cannot block replacement, removal, or disposal. + } + finally + { + _lifetime.Dispose(); + } + } + + private void ClearRefresh(Task completed) + { + lock (_refreshGate) + { + if (ReferenceEquals(_inflightRefresh, completed)) + { + _inflightRefresh = null; + } + } + } + } +} diff --git a/src/OpenGameAgent.Media/OpenGameAgent.Media.csproj b/src/OpenGameAgent.Media/OpenGameAgent.Media.csproj new file mode 100644 index 0000000..e2327d9 --- /dev/null +++ b/src/OpenGameAgent.Media/OpenGameAgent.Media.csproj @@ -0,0 +1,11 @@ + + + netstandard2.1 + Optional authenticated image, audio, and video model registry and generation dispatcher for OpenGameAgent. + OpenGameAgent.Media + + + + + + diff --git a/src/OpenGameAgent.Media/packages.lock.json b/src/OpenGameAgent.Media/packages.lock.json new file mode 100644 index 0000000..99754ad --- /dev/null +++ b/src/OpenGameAgent.Media/packages.lock.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/AssemblyInfo.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/AssemblyInfo.cs new file mode 100644 index 0000000..15631d2 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenGameAgent.Models.Auth.BuiltIn.Tests")] diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedDeviceOAuth.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedDeviceOAuth.cs new file mode 100644 index 0000000..3c3e467 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedDeviceOAuth.cs @@ -0,0 +1,380 @@ +using System.Text.Json; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal sealed class DeviceOAuthOptions +{ + public DeviceOAuthOptions( + Uri deviceEndpoint, + Uri tokenEndpoint, + string clientId, + IReadOnlyCollection allowedVerificationHosts, + OAuthRuntimeSettings runtime) + { + DeviceEndpoint = BoundedOAuthHttp.RequireHttps(deviceEndpoint, nameof(deviceEndpoint)); + TokenEndpoint = BoundedOAuthHttp.RequireHttps(tokenEndpoint, nameof(tokenEndpoint)); + ClientId = RequireValue(clientId, nameof(clientId)); + AllowedVerificationHosts = Array.AsReadOnly( + (allowedVerificationHosts ?? throw new ArgumentNullException(nameof(allowedVerificationHosts))) + .Select(host => RequireValue(host, nameof(allowedVerificationHosts))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray()); + if (AllowedVerificationHosts.Count == 0 || AllowedVerificationHosts.Count > 16) + { + throw new ArgumentException("At least one bounded verification host is required.", nameof(allowedVerificationHosts)); + } + + Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public Uri DeviceEndpoint { get; } + + public Uri TokenEndpoint { get; } + + public string ClientId { get; } + + public IReadOnlyCollection AllowedVerificationHosts { get; } + + public OAuthRuntimeSettings Runtime { get; } + + public IList Scopes { get; } = new List(); + + public IDictionary DeviceParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + public IDictionary TokenParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + public TimeSpan DefaultTokenLifetime { get; set; } = TimeSpan.FromHours(1); + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 4096 + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A bounded non-empty OAuth value is required.", parameterName); + } + + return value; + } +} + +internal static class BoundedDeviceOAuth +{ + private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan MinimumPollInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MaximumPollInterval = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MaximumDeviceLifetime = TimeSpan.FromMinutes(30); + private static readonly TimeSpan MaximumTokenLifetime = TimeSpan.FromDays(365); + + public static async ValueTask LoginAsync( + DeviceOAuthOptions options, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var deviceFields = Merge(options.DeviceParameters, new Dictionary(StringComparer.Ordinal) + { + ["client_id"] = options.ClientId, + }); + var scopes = NormalizeScopes(options.Scopes); + if (scopes.Count > 0) + { + deviceFields["scope"] = string.Join(" ", scopes); + } + + using var deviceResponse = await BoundedOAuthHttp.PostFormAsync( + options.Runtime.HttpClient, + options.DeviceEndpoint, + deviceFields, + options.Runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!deviceResponse.IsSuccess) + { + throw BoundedOAuthHttp.Failure("Device authorization", deviceResponse); + } + + var root = deviceResponse.Root; + var deviceCode = BoundedOAuthHttp.RequiredString(root, "device_code"); + var userCode = BoundedOAuthHttp.RequiredString(root, "user_code", 4096); + var verificationText = BoundedOAuthHttp.OptionalString(root, "verification_uri_complete", 16_384) + ?? BoundedOAuthHttp.RequiredString(root, "verification_uri", 16_384); + var verificationUri = ValidateVerificationUri(verificationText, options.AllowedVerificationHosts); + var expiresIn = BoundedOAuthHttp.ReadSeconds( + root, + "expires_in", + TimeSpan.FromMinutes(15), + TimeSpan.FromSeconds(1), + MaximumDeviceLifetime); + var interval = ReadPollInterval(root, "interval", DefaultPollInterval); + + if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync( + $"Enter device code {userCode} at {verificationUri}", + cancellationToken).ConfigureAwait(false); + } + + if (interaction.OpenBrowserAsync is not null) + { + await interaction.OpenBrowserAsync(verificationUri, cancellationToken).ConfigureAwait(false); + } + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var totalLifetime = expiresIn < options.Runtime.LoginTimeout ? expiresIn : options.Runtime.LoginTimeout; + lifetime.CancelAfter(totalLifetime); + var pollInterval = interval; + try + { + while (true) + { + await BoundedOAuthHttp.WaitAsync( + options.Runtime.DelayAsync(pollInterval, lifetime.Token), + lifetime.Token).ConfigureAwait(false); + lifetime.Token.ThrowIfCancellationRequested(); + var fields = Merge(options.TokenParameters, new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", + ["client_id"] = options.ClientId, + ["device_code"] = deviceCode, + }); + using var response = await BoundedOAuthHttp.PostFormAsync( + options.Runtime.HttpClient, + options.TokenEndpoint, + fields, + options.Runtime.RequestTimeout, + lifetime.Token).ConfigureAwait(false); + if (response.IsSuccess) + { + var credential = ParseCredential( + response.Root, + options.Runtime.Clock, + options.DefaultTokenLifetime, + previousRefreshToken: null, + requireRefreshToken: true); + cancellationToken.ThrowIfCancellationRequested(); + return credential; + } + + var error = BoundedOAuthHttp.OptionalString(response.Root, "error", 4096); + if (string.Equals(error, "authorization_pending", StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(error, "slow_down", StringComparison.Ordinal)) + { + var serverInterval = TryReadPollInterval(response.Root, "interval"); + pollInterval = serverInterval is { } provided && provided > pollInterval + ? provided + : pollInterval + TimeSpan.FromSeconds(5); + if (pollInterval > MaximumPollInterval) + { + pollInterval = MaximumPollInterval; + } + + continue; + } + + if (error is "access_denied" or "authorization_denied") + { + throw new InvalidOperationException("Device authorization was denied."); + } + + if (string.Equals(error, "expired_token", StringComparison.Ordinal)) + { + throw new TimeoutException("The device authorization code expired."); + } + + throw BoundedOAuthHttp.Failure("Device token polling", response); + } + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested && lifetime.IsCancellationRequested) + { + throw new TimeoutException("The device authorization flow timed out.", exception); + } + } + + public static async ValueTask RefreshAsync( + DeviceOAuthOptions options, + GameCredential credential, + CancellationToken cancellationToken) + { + if (credential is null || credential.Kind != GameCredentialKind.OAuth) + { + throw new ArgumentException("An OAuth credential is required.", nameof(credential)); + } + + if (!credential.Metadata.TryGetValue("refresh_token", out var refreshToken) + || string.IsNullOrWhiteSpace(refreshToken)) + { + throw new InvalidOperationException("The OAuth credential has no refresh token."); + } + + var fields = Merge(options.TokenParameters, new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["client_id"] = options.ClientId, + ["refresh_token"] = refreshToken, + }); + using var response = await BoundedOAuthHttp.PostFormAsync( + options.Runtime.HttpClient, + options.TokenEndpoint, + fields, + options.Runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("OAuth token refresh", response); + } + + return ParseCredential( + response.Root, + options.Runtime.Clock, + options.DefaultTokenLifetime, + refreshToken, + requireRefreshToken: false); + } + + internal static GameCredential ParseCredential( + JsonElement root, + Func clock, + TimeSpan defaultLifetime, + string? previousRefreshToken, + bool requireRefreshToken) + { + var accessToken = BoundedOAuthHttp.RequiredString(root, "access_token"); + var refreshToken = BoundedOAuthHttp.OptionalString(root, "refresh_token") ?? previousRefreshToken; + if (requireRefreshToken && string.IsNullOrWhiteSpace(refreshToken)) + { + throw new InvalidOperationException("The OAuth response omitted 'refresh_token'."); + } + + var lifetime = BoundedOAuthHttp.ReadSeconds( + root, + "expires_in", + defaultLifetime, + TimeSpan.FromSeconds(1), + MaximumTokenLifetime); + var metadata = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(refreshToken)) + { + metadata["refresh_token"] = refreshToken!; + } + + var tokenType = BoundedOAuthHttp.OptionalString(root, "token_type", 256); + if (!string.IsNullOrWhiteSpace(tokenType)) + { + metadata["token_type"] = tokenType!; + } + + var scope = BoundedOAuthHttp.OptionalString(root, "scope", 16_384); + if (!string.IsNullOrWhiteSpace(scope)) + { + metadata["scope"] = scope!; + } + + return new GameCredential(GameCredentialKind.OAuth, accessToken, clock() + lifetime, metadata); + } + + private static Uri ValidateVerificationUri( + string raw, + IReadOnlyCollection allowedHosts) + { + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps + || uri.UserInfo.Length != 0 + || uri.Fragment.Length != 0 + || !allowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The device authorization response contained an untrusted verification URL."); + } + + return uri; + } + + private static TimeSpan ReadPollInterval(JsonElement root, string name, TimeSpan fallback) => + TryReadPollInterval(root, name) ?? fallback; + + private static TimeSpan? TryReadPollInterval(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out _)) + { + return null; + } + + try + { + return BoundedOAuthHttp.ReadSeconds( + root, + name, + DefaultPollInterval, + MinimumPollInterval, + MaximumPollInterval); + } + catch (InvalidOperationException) + { + return DefaultPollInterval; + } + } + + private static IReadOnlyList NormalizeScopes(IList scopes) + { + if (scopes.Count > 64) + { + throw new ArgumentException("At most 64 OAuth scopes are supported.", nameof(scopes)); + } + + return scopes + .Select(scope => RequireField(scope, 4096)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static Dictionary Merge( + IEnumerable> configured, + IReadOnlyDictionary required) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in configured) + { + result.Add(RequireField(pair.Key, 256), RequireField(pair.Value, 65_536)); + } + + foreach (var pair in required) + { + result[pair.Key] = pair.Value; + } + + if (result.Count > 64) + { + throw new ArgumentException("At most 64 OAuth parameters are supported.", nameof(configured)); + } + + return result; + } + + private static string RequireField(string value, int maximum) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > maximum + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("An OAuth parameter is invalid."); + } + + return value; + } +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedOAuthHttp.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedOAuthHttp.cs new file mode 100644 index 0000000..9fb26dc --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/BoundedOAuthHttp.cs @@ -0,0 +1,325 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal static class BoundedOAuthHttp +{ + private const int MaximumResponseBytes = 1_000_000; + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + + public static ValueTask PostFormAsync( + HttpClient client, + Uri endpoint, + IReadOnlyDictionary fields, + TimeSpan timeout, + CancellationToken cancellationToken) => + PostAsync( + client, + endpoint, + new FormUrlEncodedContent(ValidateFields(fields)), + timeout, + cancellationToken); + + public static ValueTask PostJsonAsync( + HttpClient client, + Uri endpoint, + IReadOnlyDictionary fields, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(ValidateFields(fields)); + return PostAsync( + client, + endpoint, + new StringContent(json, Encoding.UTF8, "application/json"), + timeout, + cancellationToken); + } + + private static async ValueTask PostAsync( + HttpClient client, + Uri endpoint, + HttpContent content, + TimeSpan timeout, + CancellationToken cancellationToken) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + RequireHttps(endpoint, nameof(endpoint)); + if (timeout < TimeSpan.FromMilliseconds(100) || timeout > TimeSpan.FromMinutes(5)) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + using (content) + using (var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + operation.CancelAfter(timeout); + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { Content = content }; + request.Headers.Accept.ParseAdd("application/json"); + using var response = await WaitAsync( + client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, operation.Token), + operation.Token).ConfigureAwait(false); + var body = await ReadBoundedAsync(response.Content, operation.Token).ConfigureAwait(false); + JsonDocument document; + try + { + document = JsonDocument.Parse(body, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + } + catch (JsonException exception) + { + throw new InvalidOperationException("The OAuth endpoint returned invalid JSON.", exception); + } + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + document.Dispose(); + throw new InvalidOperationException("The OAuth endpoint returned a non-object response."); + } + + return new OAuthJsonResponse(response.StatusCode, document); + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested && operation.IsCancellationRequested) + { + throw new TimeoutException("The OAuth HTTP request timed out.", exception); + } + } + } + + private static async ValueTask ReadBoundedAsync( + HttpContent content, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > MaximumResponseBytes) + { + throw new InvalidOperationException("The OAuth response exceeded its safety bound."); + } + + var stream = await WaitAsync(content.ReadAsStreamAsync(), cancellationToken).ConfigureAwait(false); + using (stream) + using (var buffer = new MemoryStream()) + { + var chunk = new byte[8192]; + while (true) + { + var read = await WaitAsync( + stream.ReadAsync(chunk, 0, chunk.Length, cancellationToken), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > MaximumResponseBytes) + { + throw new InvalidOperationException("The OAuth response exceeded its safety bound."); + } + + buffer.Write(chunk, 0, read); + } + + try + { + return StrictUtf8.GetString(buffer.ToArray()); + } + catch (DecoderFallbackException exception) + { + throw new InvalidOperationException("The OAuth response is not valid UTF-8.", exception); + } + } + } + + private static IReadOnlyDictionary ValidateFields( + IReadOnlyDictionary fields) + { + if (fields is null || fields.Count > 64) + { + throw new ArgumentException("OAuth requests support at most 64 fields.", nameof(fields)); + } + + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in fields) + { + if (!IsBoundedValue(pair.Key, 256) || !IsBoundedValue(pair.Value, 65_536)) + { + throw new ArgumentException("An OAuth request field is invalid.", nameof(fields)); + } + + copy.Add(pair.Key, pair.Value); + } + + return copy; + } + + internal static Uri RequireHttps(Uri value, string parameterName) + { + if (value is null + || !value.IsAbsoluteUri + || value.Scheme != Uri.UriSchemeHttps + || value.UserInfo.Length != 0 + || value.Fragment.Length != 0) + { + throw new ArgumentException("An absolute HTTPS endpoint without credentials or a fragment is required.", parameterName); + } + + return value; + } + + internal static string RequiredString(JsonElement root, string name, int maximum = 65_536) + { + if (!root.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.String + || !IsBoundedValue(value.GetString(), maximum)) + { + throw new InvalidOperationException($"The OAuth response omitted or invalidated '{name}'."); + } + + return value.GetString()!; + } + + internal static string? OptionalString(JsonElement root, string name, int maximum = 65_536) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String || !IsBoundedValue(value.GetString(), maximum)) + { + throw new InvalidOperationException($"The OAuth response field '{name}' is invalid."); + } + + return value.GetString(); + } + + internal static TimeSpan ReadSeconds( + JsonElement root, + string name, + TimeSpan fallback, + TimeSpan minimum, + TimeSpan maximum) + { + if (!root.TryGetProperty(name, out var value)) + { + return fallback; + } + + double seconds; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out seconds)) + { + } + else if (value.ValueKind == JsonValueKind.String + && double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + } + else + { + throw new InvalidOperationException($"The OAuth response field '{name}' is invalid."); + } + + if (double.IsNaN(seconds) + || double.IsInfinity(seconds) + || seconds < minimum.TotalSeconds + || seconds > maximum.TotalSeconds) + { + throw new InvalidOperationException($"The OAuth response field '{name}' is outside its allowed range."); + } + + return TimeSpan.FromSeconds(seconds); + } + + internal static InvalidOperationException Failure(string operation, OAuthJsonResponse response) + { + var error = OptionalString(response.Root, "error", 4096); + return new InvalidOperationException( + $"{operation} failed with HTTP {(int)response.StatusCode}" + + (string.IsNullOrWhiteSpace(error) ? "." : $" ({error}).")); + } + + internal static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + if (task.IsCompleted) + { + return await task.ConfigureAwait(false); + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => canceled.TrySetResult(true))) + { + if (task != await Task.WhenAny(task, canceled.Task).ConfigureAwait(false)) + { + _ = task.ContinueWith( + completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw new OperationCanceledException(cancellationToken); + } + } + + return await task.ConfigureAwait(false); + } + + internal static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + if (task.IsCompleted) + { + await task.ConfigureAwait(false); + return; + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => canceled.TrySetResult(true))) + { + if (task != await Task.WhenAny(task, canceled.Task).ConfigureAwait(false)) + { + _ = task.ContinueWith( + completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw new OperationCanceledException(cancellationToken); + } + } + + await task.ConfigureAwait(false); + } + + private static bool IsBoundedValue(string? value, int maximum) => + !string.IsNullOrWhiteSpace(value) + && value.Length <= maximum + && value.IndexOfAny(new[] { '\r', '\n', '\0' }) < 0; +} + +internal sealed class OAuthJsonResponse : IDisposable +{ + private readonly JsonDocument _document; + + public OAuthJsonResponse(HttpStatusCode statusCode, JsonDocument document) + { + StatusCode = statusCode; + _document = document; + } + + public HttpStatusCode StatusCode { get; } + + public bool IsSuccess => (int)StatusCode is >= 200 and <= 299; + + public JsonElement Root => _document.RootElement; + + public void Dispose() => _document.Dispose(); +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthOptions.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthOptions.cs new file mode 100644 index 0000000..762033b --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthOptions.cs @@ -0,0 +1,156 @@ +namespace OpenGameAgent.Models.Auth.BuiltIn; + +public sealed class BuiltInGameOAuthOptions +{ + public BuiltInGameOAuthOptions(HttpClient httpClient, IGameCredentialStore credentialStore) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + CredentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore)); + } + + public HttpClient HttpClient { get; } + + public IGameCredentialStore CredentialStore { get; } + + public string Profile { get; set; } = "default"; + + public TimeSpan LoginTimeout { get; set; } = TimeSpan.FromMinutes(5); + + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan RefreshSkew { get; set; } = TimeSpan.FromMinutes(5); + + public string? AnthropicClientId { get; set; } + + public string? XaiClientId { get; set; } + + public string? KimiForCodingClientId { get; set; } + + public string? OpenAICodexClientId { get; set; } + + public string OpenAICodexOriginator { get; set; } = "opengameagent"; + + public Func Clock { get; set; } = () => DateTimeOffset.UtcNow; + + public Func DelayAsync { get; set; } = Task.Delay; + + internal OAuthRuntimeSettings Snapshot() + { + var profile = RequireId(Profile, nameof(Profile)); + if (LoginTimeout < TimeSpan.FromSeconds(10) || LoginTimeout > TimeSpan.FromMinutes(30)) + { + throw new ArgumentOutOfRangeException(nameof(LoginTimeout)); + } + + if (RequestTimeout < TimeSpan.FromMilliseconds(100) || RequestTimeout > TimeSpan.FromMinutes(5)) + { + throw new ArgumentOutOfRangeException(nameof(RequestTimeout)); + } + + if (RefreshSkew < TimeSpan.Zero || RefreshSkew > TimeSpan.FromHours(24)) + { + throw new ArgumentOutOfRangeException(nameof(RefreshSkew)); + } + + return new OAuthRuntimeSettings( + HttpClient, + CredentialStore, + profile, + LoginTimeout, + RequestTimeout, + RefreshSkew, + OptionalClientId(AnthropicClientId, nameof(AnthropicClientId)), + OptionalClientId(XaiClientId, nameof(XaiClientId)), + OptionalClientId(KimiForCodingClientId, nameof(KimiForCodingClientId)), + OptionalClientId(OpenAICodexClientId, nameof(OpenAICodexClientId)), + RequireValue(OpenAICodexOriginator, 256, nameof(OpenAICodexOriginator)), + Clock ?? throw new ArgumentException("An OAuth clock is required.", nameof(Clock)), + DelayAsync ?? throw new ArgumentException("An OAuth delay strategy is required.", nameof(DelayAsync))); + } + + private static string RequireId(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 256 + || value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new ArgumentException("A bounded non-empty profile identifier is required.", parameterName); + } + + return value; + } + + private static string? OptionalClientId(string? value, string parameterName) => + value is null ? null : RequireValue(value, 4096, parameterName); + + private static string RequireValue(string value, int maximum, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > maximum + || value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new ArgumentException("A bounded non-empty OAuth identifier is required.", parameterName); + } + + return value; + } +} + +internal sealed class OAuthRuntimeSettings +{ + public OAuthRuntimeSettings( + HttpClient httpClient, + IGameCredentialStore credentialStore, + string profile, + TimeSpan loginTimeout, + TimeSpan requestTimeout, + TimeSpan refreshSkew, + string? anthropicClientId, + string? xaiClientId, + string? kimiForCodingClientId, + string? openAICodexClientId, + string openAICodexOriginator, + Func clock, + Func delayAsync) + { + HttpClient = httpClient; + CredentialStore = credentialStore; + Profile = profile; + LoginTimeout = loginTimeout; + RequestTimeout = requestTimeout; + RefreshSkew = refreshSkew; + AnthropicClientId = anthropicClientId; + XaiClientId = xaiClientId; + KimiForCodingClientId = kimiForCodingClientId; + OpenAICodexClientId = openAICodexClientId; + OpenAICodexOriginator = openAICodexOriginator; + Clock = clock; + DelayAsync = delayAsync; + } + + public HttpClient HttpClient { get; } + + public IGameCredentialStore CredentialStore { get; } + + public string Profile { get; } + + public TimeSpan LoginTimeout { get; } + + public TimeSpan RequestTimeout { get; } + + public TimeSpan RefreshSkew { get; } + + public string? AnthropicClientId { get; } + + public string? XaiClientId { get; } + + public string? KimiForCodingClientId { get; } + + public string? OpenAICodexClientId { get; } + + public string OpenAICodexOriginator { get; } + + public Func Clock { get; } + + public Func DelayAsync { get; } +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthRegistration.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthRegistration.cs new file mode 100644 index 0000000..394c33e --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameOAuthRegistration.cs @@ -0,0 +1,116 @@ +using OpenGameAgent.Models.BuiltIn; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +public static class BuiltInGameOAuthRegistration +{ + private static readonly IReadOnlyCollection ProviderIds = Array.AsReadOnly(new[] + { + BuiltInGameProviderAuthentications.AnthropicProviderId, + BuiltInGameProviderAuthentications.OpenRouterProviderId, + BuiltInGameProviderAuthentications.XaiProviderId, + BuiltInGameProviderAuthentications.KimiForCodingProviderId, + BuiltInGameProviderAuthentications.OpenAICodexProviderId, + }); + + public static IReadOnlyCollection SupportedProviderIds => ProviderIds; + + public static IGameProviderAuthentication Create( + string providerId, + BuiltInGameOAuthOptions options) + { + var id = RequireProviderId(providerId, nameof(providerId)); + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return id switch + { + BuiltInGameProviderAuthentications.AnthropicProviderId => + BuiltInGameProviderAuthentications.CreateAnthropic(options), + BuiltInGameProviderAuthentications.OpenRouterProviderId => + BuiltInGameProviderAuthentications.CreateOpenRouter(options), + BuiltInGameProviderAuthentications.XaiProviderId => + BuiltInGameProviderAuthentications.CreateXai(options), + BuiltInGameProviderAuthentications.KimiForCodingProviderId => + BuiltInGameProviderAuthentications.CreateKimiForCoding(options), + BuiltInGameProviderAuthentications.OpenAICodexProviderId => + BuiltInGameProviderAuthentications.CreateOpenAICodex(options), + _ => throw new KeyNotFoundException( + $"Provider '{id}' does not have a built-in OAuth registration."), + }; + } + + public static bool TryCreate( + string providerId, + BuiltInGameOAuthOptions options, + out IGameProviderAuthentication? authentication) + { + var id = RequireProviderId(providerId, nameof(providerId)); + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (!ProviderIds.Contains(id, StringComparer.Ordinal)) + { + authentication = null; + return false; + } + + authentication = Create(id, options); + return true; + } + + public static int RegisterBuiltInOAuth( + this BuiltInGameModelRuntimeOptions runtimeOptions, + BuiltInGameOAuthOptions authenticationOptions, + bool replaceExisting = false) + { + if (runtimeOptions is null) + { + throw new ArgumentNullException(nameof(runtimeOptions)); + } + + if (authenticationOptions is null) + { + throw new ArgumentNullException(nameof(authenticationOptions)); + } + + var directory = runtimeOptions.Directory + ?? throw new ArgumentException( + "A model directory is required before OAuth registration.", + nameof(runtimeOptions)); + var registered = 0; + foreach (var providerId in ProviderIds) + { + if (directory.GetProvider(providerId) is null) + { + continue; + } + + if (!replaceExisting && runtimeOptions.Authentications.ContainsKey(providerId)) + { + continue; + } + + runtimeOptions.Authentications[providerId] = Create(providerId, authenticationOptions); + registered++; + } + + return registered; + } + + private static string RequireProviderId(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 512 + || value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new ArgumentException("A bounded provider identifier is required.", parameterName); + } + + return value; + } +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameProviderAuthentications.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameProviderAuthentications.cs new file mode 100644 index 0000000..7798124 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/BuiltInGameProviderAuthentications.cs @@ -0,0 +1,439 @@ +using System.Net; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +public static class BuiltInGameProviderAuthentications +{ + public const string AnthropicProviderId = "anthropic"; + public const string OpenRouterProviderId = "openrouter"; + public const string XaiProviderId = "xai"; + public const string KimiForCodingProviderId = "kimi-for-coding"; + public const string OpenAICodexProviderId = "openai-codex"; + + private const string AnthropicScopes = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; + private const string XaiScopes = "openid profile email offline_access grok-cli:access api:access"; + + private static readonly Uri AnthropicAuthorizationEndpoint = + new("https://claude.ai/oauth/authorize", UriKind.Absolute); + private static readonly Uri AnthropicTokenEndpoint = + new("https://platform.claude.com/v1/oauth/token", UriKind.Absolute); + private static readonly Uri OpenRouterAuthorizationEndpoint = + new("https://openrouter.ai/auth", UriKind.Absolute); + private static readonly Uri OpenRouterTokenEndpoint = + new("https://openrouter.ai/api/v1/auth/keys", UriKind.Absolute); + private static readonly Uri XaiDeviceEndpoint = + new("https://auth.x.ai/oauth2/device/code", UriKind.Absolute); + private static readonly Uri XaiTokenEndpoint = + new("https://auth.x.ai/oauth2/token", UriKind.Absolute); + private static readonly Uri KimiDeviceEndpoint = + new("https://auth.kimi.com/api/oauth/device_authorization", UriKind.Absolute); + private static readonly Uri KimiTokenEndpoint = + new("https://auth.kimi.com/api/oauth/token", UriKind.Absolute); + + public static IGameProviderAuthentication CreateAnthropic(BuiltInGameOAuthOptions options) + { + var runtime = Require(options).Snapshot(); + if (runtime.AnthropicClientId is null) + { + return ClientRegistrationRequired( + AnthropicProviderId, + ApiKeyEnvironment("ANTHROPIC_API_KEY")); + } + + var stored = new StoredGameProviderAuthentication( + AnthropicProviderId, + runtime.CredentialStore, + new[] { "oauth-anthropic-subscription" }, + (_, interaction, cancellationToken) => LoginAnthropicAsync(runtime, interaction, cancellationToken), + (credential, cancellationToken) => RefreshAnthropicAsync(runtime, credential, cancellationToken), + runtime.Profile, + runtime.Clock, + runtime.RefreshSkew, + RefreshTimeoutMilliseconds(runtime)); + return WithApiKeyFallback( + new ResolutionOverlayAuthentication( + stored, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["anthropic-beta"] = "oauth-2025-04-20", + }), + "ANTHROPIC_API_KEY"); + } + + public static IGameProviderAuthentication CreateOpenRouter(BuiltInGameOAuthOptions options) + { + var runtime = Require(options).Snapshot(); + var stored = new StoredGameProviderAuthentication( + OpenRouterProviderId, + runtime.CredentialStore, + new[] { "oauth-openrouter" }, + (_, interaction, cancellationToken) => LoginOpenRouterAsync(runtime, interaction, cancellationToken), + refresh: null, + runtime.Profile, + runtime.Clock, + runtime.RefreshSkew, + RefreshTimeoutMilliseconds(runtime)); + return WithApiKeyFallback(stored, "OPENROUTER_API_KEY"); + } + + public static IGameProviderAuthentication CreateXai(BuiltInGameOAuthOptions options) + { + var runtime = Require(options).Snapshot(); + if (runtime.XaiClientId is null) + { + return ClientRegistrationRequired(XaiProviderId, ApiKeyEnvironment("XAI_API_KEY")); + } + + var device = XaiDeviceOptions(runtime); + var stored = new StoredGameProviderAuthentication( + XaiProviderId, + runtime.CredentialStore, + new[] { "oauth-xai-device-code" }, + (_, interaction, cancellationToken) => + BoundedDeviceOAuth.LoginAsync(device, interaction, cancellationToken), + (credential, cancellationToken) => + BoundedDeviceOAuth.RefreshAsync(device, credential, cancellationToken), + runtime.Profile, + runtime.Clock, + runtime.RefreshSkew, + RefreshTimeoutMilliseconds(runtime)); + return WithApiKeyFallback(stored, "XAI_API_KEY"); + } + + public static IGameProviderAuthentication CreateKimiForCoding(BuiltInGameOAuthOptions options) + { + var runtime = Require(options).Snapshot(); + if (runtime.KimiForCodingClientId is null) + { + return ClientRegistrationRequired( + KimiForCodingProviderId, + ApiKeyEnvironment("KIMI_API_KEY")); + } + + var device = KimiDeviceOptions(runtime); + var stored = new StoredGameProviderAuthentication( + KimiForCodingProviderId, + runtime.CredentialStore, + new[] { "oauth-kimi-device-code" }, + (_, interaction, cancellationToken) => + BoundedDeviceOAuth.LoginAsync(device, interaction, cancellationToken), + (credential, cancellationToken) => + RefreshKimiWithRetryAsync(device, credential, cancellationToken), + runtime.Profile, + runtime.Clock, + runtime.RefreshSkew, + RefreshTimeoutMilliseconds(runtime)); + return WithApiKeyFallback(stored, "KIMI_API_KEY"); + } + + public static IGameProviderAuthentication CreateOpenAICodex(BuiltInGameOAuthOptions options) + { + var runtime = Require(options).Snapshot(); + if (runtime.OpenAICodexClientId is null) + { + return ClientRegistrationRequired( + OpenAICodexProviderId, + new EnvironmentGameProviderAuthentication( + "OPENAI_CODEX_ACCESS_TOKEN", + GameCredentialKind.BearerToken)); + } + + var stored = new StoredGameProviderAuthentication( + OpenAICodexProviderId, + runtime.CredentialStore, + new[] { "oauth-openai-codex-browser", "oauth-openai-codex-device-code" }, + (scheme, interaction, cancellationToken) => scheme switch + { + "oauth-openai-codex-browser" => + OpenAICodexOAuth.LoginBrowserAsync(runtime, interaction, cancellationToken), + "oauth-openai-codex-device-code" => + OpenAICodexOAuth.LoginDeviceAsync(runtime, interaction, cancellationToken), + _ => throw new InvalidOperationException("The requested OAuth login scheme is not supported."), + }, + (credential, cancellationToken) => + OpenAICodexOAuth.RefreshAsync(runtime, credential, cancellationToken), + runtime.Profile, + runtime.Clock, + runtime.RefreshSkew, + RefreshTimeoutMilliseconds(runtime)); + return new FallbackGameProviderAuthentication( + stored, + new EnvironmentGameProviderAuthentication( + "OPENAI_CODEX_ACCESS_TOKEN", + GameCredentialKind.BearerToken)); + } + + private static ValueTask LoginAnthropicAsync( + OAuthRuntimeSettings runtime, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + SecureLoopbackOAuth.LoginAsync( + new SecureLoopbackOAuthOptions( + AnthropicAuthorizationEndpoint, + "localhost", + "/callback", + runtime.LoginTimeout, + (redirectUri, challenge, state) => BuildUri( + AnthropicAuthorizationEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["code"] = "true", + ["client_id"] = runtime.AnthropicClientId!, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["scope"] = AnthropicScopes, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + ["state"] = state, + }), + (code, verifier, state, redirectUri, token) => ExchangeAnthropicAsync( + runtime, + code, + verifier, + state, + redirectUri, + token), + port: 53_692, + statePlacement: LoopbackStatePlacement.Query), + interaction, + cancellationToken); + + private static async ValueTask ExchangeAnthropicAsync( + OAuthRuntimeSettings runtime, + string code, + string verifier, + string state, + Uri redirectUri, + CancellationToken cancellationToken) + { + using var response = await BoundedOAuthHttp.PostJsonAsync( + runtime.HttpClient, + AnthropicTokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["client_id"] = runtime.AnthropicClientId!, + ["code"] = code, + ["state"] = state, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["code_verifier"] = verifier, + }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("Anthropic token exchange", response); + } + + return BoundedDeviceOAuth.ParseCredential( + response.Root, + runtime.Clock, + TimeSpan.FromHours(1), + previousRefreshToken: null, + requireRefreshToken: true); + } + + private static async ValueTask RefreshAnthropicAsync( + OAuthRuntimeSettings runtime, + GameCredential credential, + CancellationToken cancellationToken) + { + var refreshToken = RequireRefreshToken(credential); + using var response = await BoundedOAuthHttp.PostJsonAsync( + runtime.HttpClient, + AnthropicTokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["client_id"] = runtime.AnthropicClientId!, + ["refresh_token"] = refreshToken, + }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("Anthropic token refresh", response); + } + + return BoundedDeviceOAuth.ParseCredential( + response.Root, + runtime.Clock, + TimeSpan.FromHours(1), + refreshToken, + requireRefreshToken: false); + } + + private static ValueTask LoginOpenRouterAsync( + OAuthRuntimeSettings runtime, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + SecureLoopbackOAuth.LoginAsync( + new SecureLoopbackOAuthOptions( + OpenRouterAuthorizationEndpoint, + "127.0.0.1", + "/oauth/callback", + runtime.LoginTimeout, + (redirectUri, challenge, _) => BuildUri( + OpenRouterAuthorizationEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["callback_url"] = redirectUri.AbsoluteUri, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + }), + (code, verifier, _, _, token) => ExchangeOpenRouterAsync( + runtime, + code, + verifier, + token), + statePlacement: LoopbackStatePlacement.CallbackPath), + interaction, + cancellationToken); + + private static async ValueTask ExchangeOpenRouterAsync( + OAuthRuntimeSettings runtime, + string code, + string verifier, + CancellationToken cancellationToken) + { + using var response = await BoundedOAuthHttp.PostJsonAsync( + runtime.HttpClient, + OpenRouterTokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["code"] = code, + ["code_verifier"] = verifier, + ["code_challenge_method"] = "S256", + }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("OpenRouter key exchange", response); + } + + var key = BoundedOAuthHttp.RequiredString(response.Root, "key"); + return new GameCredential( + GameCredentialKind.OAuth, + key, + metadata: new Dictionary(StringComparer.Ordinal) + { + ["credential_type"] = "user-controlled-api-key", + }); + } + + private static DeviceOAuthOptions XaiDeviceOptions(OAuthRuntimeSettings runtime) + { + var options = new DeviceOAuthOptions( + XaiDeviceEndpoint, + XaiTokenEndpoint, + runtime.XaiClientId!, + new[] { "accounts.x.ai", "auth.x.ai" }, + runtime); + foreach (var scope in XaiScopes.Split(' ')) + { + options.Scopes.Add(scope); + } + + options.DeviceParameters["referrer"] = "opengameagent"; + options.DefaultTokenLifetime = TimeSpan.FromHours(1); + return options; + } + + private static DeviceOAuthOptions KimiDeviceOptions(OAuthRuntimeSettings runtime) => + new( + KimiDeviceEndpoint, + KimiTokenEndpoint, + runtime.KimiForCodingClientId!, + new[] { "auth.kimi.com" }, + runtime) + { + DefaultTokenLifetime = TimeSpan.FromHours(1), + }; + + private static async ValueTask RefreshKimiWithRetryAsync( + DeviceOAuthOptions options, + GameCredential credential, + CancellationToken cancellationToken) + { + var refreshToken = RequireRefreshToken(credential); + for (var attempt = 0; attempt < 3; attempt++) + { + using var response = await BoundedOAuthHttp.PostFormAsync( + options.Runtime.HttpClient, + options.TokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["client_id"] = options.ClientId, + ["refresh_token"] = refreshToken, + }, + options.Runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (response.IsSuccess) + { + return BoundedDeviceOAuth.ParseCredential( + response.Root, + options.Runtime.Clock, + options.DefaultTokenLifetime, + refreshToken, + requireRefreshToken: false); + } + + var retryable = response.StatusCode == HttpStatusCode.TooManyRequests + || (int)response.StatusCode is >= 500 and <= 599; + if (!retryable || attempt == 2) + { + throw BoundedOAuthHttp.Failure("Kimi token refresh", response); + } + + await BoundedOAuthHttp.WaitAsync( + options.Runtime.DelayAsync(TimeSpan.FromMilliseconds(250 * (1 << attempt)), cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + throw new InvalidOperationException("Kimi token refresh failed."); + } + + private static IGameProviderAuthentication WithApiKeyFallback( + IGameProviderAuthentication stored, + string environmentVariable) => + new FallbackGameProviderAuthentication( + stored, + ApiKeyEnvironment(environmentVariable)); + + private static IGameProviderAuthentication ApiKeyEnvironment(string environmentVariable) => + new EnvironmentGameProviderAuthentication(environmentVariable); + + private static IGameProviderAuthentication ClientRegistrationRequired( + string providerId, + IGameProviderAuthentication fallback) => + new OAuthClientRegistrationRequiredAuthentication(providerId, fallback); + + private static BuiltInGameOAuthOptions Require(BuiltInGameOAuthOptions options) => + options ?? throw new ArgumentNullException(nameof(options)); + + private static string RequireRefreshToken(GameCredential credential) + { + if (credential is null || credential.Kind != GameCredentialKind.OAuth) + { + throw new ArgumentException("An OAuth credential is required.", nameof(credential)); + } + + return credential.Metadata.TryGetValue("refresh_token", out var refreshToken) + && !string.IsNullOrWhiteSpace(refreshToken) + ? refreshToken + : throw new InvalidOperationException("The OAuth credential has no refresh token."); + } + + private static int RefreshTimeoutMilliseconds(OAuthRuntimeSettings runtime) => + checked((int)Math.Max(100, Math.Min(300_000, runtime.RequestTimeout.TotalMilliseconds))); + + private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary fields) + { + var query = string.Join("&", fields.Select(pair => + Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))); + return new UriBuilder(endpoint) { Query = query }.Uri; + } +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/FallbackGameProviderAuthentication.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/FallbackGameProviderAuthentication.cs new file mode 100644 index 0000000..5283698 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/FallbackGameProviderAuthentication.cs @@ -0,0 +1,55 @@ +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal sealed class FallbackGameProviderAuthentication : IGameProviderAuthentication +{ + private readonly IGameProviderAuthentication _interactive; + private readonly IGameProviderAuthentication _fallback; + private readonly IReadOnlyCollection _schemes; + + public FallbackGameProviderAuthentication( + IGameProviderAuthentication interactive, + IGameProviderAuthentication fallback) + { + _interactive = interactive ?? throw new ArgumentNullException(nameof(interactive)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + _schemes = Array.AsReadOnly( + interactive.Schemes.Concat(fallback.Schemes).Distinct(StringComparer.Ordinal).ToArray()); + } + + public IReadOnlyCollection Schemes => _schemes; + + public async ValueTask CheckAsync(CancellationToken cancellationToken) + { + var interactive = await _interactive.CheckAsync(cancellationToken).ConfigureAwait(false); + return interactive.Configured + ? interactive + : await _fallback.CheckAsync(cancellationToken).ConfigureAwait(false); + } + + public async ValueTask ResolveAsync(CancellationToken cancellationToken) + { + var interactive = await _interactive.ResolveAsync(cancellationToken).ConfigureAwait(false); + return interactive ?? await _fallback.ResolveAsync(cancellationToken).ConfigureAwait(false); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + if (_interactive.Schemes.Contains(scheme, StringComparer.Ordinal)) + { + return _interactive.LoginAsync(scheme, interaction, cancellationToken); + } + + if (_fallback.Schemes.Contains(scheme, StringComparer.Ordinal)) + { + return _fallback.LoginAsync(scheme, interaction, cancellationToken); + } + + throw new InvalidOperationException($"Authentication scheme '{scheme}' is not supported."); + } + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + _interactive.LogoutAsync(cancellationToken); +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/OAuthClientRegistrationRequiredAuthentication.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/OAuthClientRegistrationRequiredAuthentication.cs new file mode 100644 index 0000000..49dfde1 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/OAuthClientRegistrationRequiredAuthentication.cs @@ -0,0 +1,43 @@ +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal sealed class OAuthClientRegistrationRequiredAuthentication : IGameProviderAuthentication +{ + private readonly IGameProviderAuthentication _fallback; + private readonly string _providerId; + + public OAuthClientRegistrationRequiredAuthentication( + string providerId, + IGameProviderAuthentication fallback) + { + if (string.IsNullOrWhiteSpace(providerId) + || providerId.Length > 512 + || providerId.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new ArgumentException("A bounded provider identifier is required.", nameof(providerId)); + } + + _providerId = providerId; + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) => + _fallback.CheckAsync(cancellationToken); + + public ValueTask ResolveAsync(CancellationToken cancellationToken) => + _fallback.ResolveAsync(cancellationToken); + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException( + $"OAuth login for provider '{_providerId}' requires an explicitly configured OAuth client ID."); + } + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + _fallback.LogoutAsync(cancellationToken); +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/OpenAICodexOAuth.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/OpenAICodexOAuth.cs new file mode 100644 index 0000000..d20e2c2 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/OpenAICodexOAuth.cs @@ -0,0 +1,353 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal static class OpenAICodexOAuth +{ + private const string Scope = "openid profile email offline_access"; + private const string AccountIdMetadata = "openai-codex.account-id"; + private static readonly Uri AuthorizationEndpoint = new("https://auth.openai.com/oauth/authorize"); + private static readonly Uri TokenEndpoint = new("https://auth.openai.com/oauth/token"); + private static readonly Uri DeviceStartEndpoint = + new("https://auth.openai.com/api/accounts/deviceauth/usercode"); + private static readonly Uri DevicePollEndpoint = + new("https://auth.openai.com/api/accounts/deviceauth/token"); + private static readonly Uri DeviceVerificationUri = new("https://auth.openai.com/codex/device"); + private static readonly Uri DeviceRedirectUri = new("https://auth.openai.com/deviceauth/callback"); + + public static ValueTask LoginBrowserAsync( + OAuthRuntimeSettings runtime, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + SecureLoopbackOAuth.LoginAsync( + new SecureLoopbackOAuthOptions( + AuthorizationEndpoint, + "localhost", + "/auth/callback", + runtime.LoginTimeout, + (redirectUri, challenge, state) => BuildUri( + AuthorizationEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["response_type"] = "code", + ["client_id"] = runtime.OpenAICodexClientId!, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["scope"] = Scope, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + ["state"] = state, + ["id_token_add_organizations"] = "true", + ["codex_cli_simplified_flow"] = "true", + ["originator"] = runtime.OpenAICodexOriginator, + }), + (code, verifier, _, redirectUri, token) => ExchangeCodeAsync( + runtime, + code, + verifier, + redirectUri, + token), + port: 1455, + statePlacement: LoopbackStatePlacement.Query), + interaction, + cancellationToken); + + public static async ValueTask LoginDeviceAsync( + OAuthRuntimeSettings runtime, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + cancellationToken.ThrowIfCancellationRequested(); + using var start = await BoundedOAuthHttp.PostJsonAsync( + runtime.HttpClient, + DeviceStartEndpoint, + new Dictionary { ["client_id"] = runtime.OpenAICodexClientId! }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!start.IsSuccess) + { + throw BoundedOAuthHttp.Failure("Device authorization", start); + } + + var deviceAuthId = BoundedOAuthHttp.RequiredString(start.Root, "device_auth_id"); + var userCode = BoundedOAuthHttp.RequiredString(start.Root, "user_code", 4096); + var interval = ReadInterval(start.Root); + if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync( + $"Enter device code {userCode} at {DeviceVerificationUri}", + cancellationToken).ConfigureAwait(false); + } + + if (interaction.OpenBrowserAsync is not null) + { + await interaction.OpenBrowserAsync(DeviceVerificationUri, cancellationToken).ConfigureAwait(false); + } + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lifetime.CancelAfter(runtime.LoginTimeout < TimeSpan.FromMinutes(15) + ? runtime.LoginTimeout + : TimeSpan.FromMinutes(15)); + var currentInterval = interval; + var firstPoll = true; + try + { + while (true) + { + if (!firstPoll) + { + await BoundedOAuthHttp.WaitAsync( + runtime.DelayAsync(currentInterval, lifetime.Token), + lifetime.Token).ConfigureAwait(false); + } + + firstPoll = false; + using var poll = await BoundedOAuthHttp.PostJsonAsync( + runtime.HttpClient, + DevicePollEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["device_auth_id"] = deviceAuthId, + ["user_code"] = userCode, + }, + runtime.RequestTimeout, + lifetime.Token).ConfigureAwait(false); + if (poll.IsSuccess) + { + var code = BoundedOAuthHttp.RequiredString(poll.Root, "authorization_code"); + var verifier = BoundedOAuthHttp.RequiredString(poll.Root, "code_verifier"); + var credential = await ExchangeCodeAsync( + runtime, + code, + verifier, + DeviceRedirectUri, + lifetime.Token).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return credential; + } + + if (poll.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.NotFound) + { + continue; + } + + var error = ReadErrorCode(poll.Root); + if (string.Equals(error, "authorization_pending", StringComparison.Ordinal) + || string.Equals(error, "deviceauth_authorization_pending", StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(error, "slow_down", StringComparison.Ordinal)) + { + currentInterval += TimeSpan.FromSeconds(5); + if (currentInterval > TimeSpan.FromMinutes(5)) + { + currentInterval = TimeSpan.FromMinutes(5); + } + + continue; + } + + throw BoundedOAuthHttp.Failure("Device authorization polling", poll); + } + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested && lifetime.IsCancellationRequested) + { + throw new TimeoutException("The device authorization flow timed out.", exception); + } + } + + public static async ValueTask RefreshAsync( + OAuthRuntimeSettings runtime, + GameCredential credential, + CancellationToken cancellationToken) + { + if (credential is null || credential.Kind != GameCredentialKind.OAuth) + { + throw new ArgumentException("An OAuth credential is required.", nameof(credential)); + } + + if (!credential.Metadata.TryGetValue("refresh_token", out var refreshToken) + || string.IsNullOrWhiteSpace(refreshToken)) + { + throw new InvalidOperationException("The OAuth credential has no refresh token."); + } + + using var response = await BoundedOAuthHttp.PostFormAsync( + runtime.HttpClient, + TokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = runtime.OpenAICodexClientId!, + }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("OAuth token refresh", response); + } + + return CredentialFromResponse(runtime, response.Root, refreshToken); + } + + private static async ValueTask ExchangeCodeAsync( + OAuthRuntimeSettings runtime, + string code, + string verifier, + Uri redirectUri, + CancellationToken cancellationToken) + { + using var response = await BoundedOAuthHttp.PostFormAsync( + runtime.HttpClient, + TokenEndpoint, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["client_id"] = runtime.OpenAICodexClientId!, + ["code"] = code, + ["code_verifier"] = verifier, + ["redirect_uri"] = redirectUri.AbsoluteUri, + }, + runtime.RequestTimeout, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess) + { + throw BoundedOAuthHttp.Failure("OAuth token exchange", response); + } + + return CredentialFromResponse(runtime, response.Root, previousRefreshToken: null); + } + + private static GameCredential CredentialFromResponse( + OAuthRuntimeSettings runtime, + JsonElement root, + string? previousRefreshToken) + { + var credential = BoundedDeviceOAuth.ParseCredential( + root, + runtime.Clock, + TimeSpan.FromHours(1), + previousRefreshToken, + requireRefreshToken: true); + var metadata = new Dictionary(credential.Metadata, StringComparer.Ordinal) + { + [AccountIdMetadata] = ExtractAccountId(credential.Secret), + }; + return new GameCredential( + GameCredentialKind.OAuth, + credential.Secret, + credential.ExpiresAt, + metadata); + } + + private static string ExtractAccountId(string accessToken) + { + try + { + var parts = accessToken.Split('.'); + if (parts.Length != 3 || parts[1].Length > 1_400_000) + { + throw new FormatException(); + } + + var encoded = parts[1].Replace('-', '+').Replace('_', '/'); + encoded = encoded.PadRight(encoded.Length + (4 - encoded.Length % 4) % 4, '='); + var bytes = Convert.FromBase64String(encoded); + if (bytes.Length > 1_000_000) + { + throw new FormatException(); + } + + using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + var accountId = document.RootElement + .GetProperty("https://api.openai.com/auth") + .GetProperty("chatgpt_account_id") + .GetString(); + if (string.IsNullOrWhiteSpace(accountId) + || accountId.Length > 512 + || accountId.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new FormatException(); + } + + return accountId; + } + catch (Exception exception) when (exception is FormatException + or JsonException + or KeyNotFoundException + or InvalidOperationException) + { + throw new InvalidOperationException( + "The OAuth access token did not contain a valid account identifier.", + exception); + } + } + + private static TimeSpan ReadInterval(JsonElement root) + { + if (!root.TryGetProperty("interval", out _)) + { + return TimeSpan.FromSeconds(5); + } + + try + { + return BoundedOAuthHttp.ReadSeconds( + root, + "interval", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(1), + TimeSpan.FromMinutes(5)); + } + catch (InvalidOperationException) + { + return TimeSpan.FromSeconds(5); + } + } + + private static string? ReadErrorCode(JsonElement root) + { + if (!root.TryGetProperty("error", out var error)) + { + return null; + } + + if (error.ValueKind == JsonValueKind.String) + { + var value = error.GetString(); + return value is { Length: <= 4096 } ? value : null; + } + + if (error.ValueKind == JsonValueKind.Object + && error.TryGetProperty("code", out var code) + && code.ValueKind == JsonValueKind.String) + { + var value = code.GetString(); + return value is { Length: <= 4096 } ? value : null; + } + + return null; + } + + private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary fields) => + new UriBuilder(endpoint) + { + Query = string.Join("&", fields.Select(pair => + Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))), + }.Uri; +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/OpenGameAgent.Models.Auth.BuiltIn.csproj b/src/OpenGameAgent.Models.Auth.BuiltIn/OpenGameAgent.Models.Auth.BuiltIn.csproj new file mode 100644 index 0000000..4ec19b2 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/OpenGameAgent.Models.Auth.BuiltIn.csproj @@ -0,0 +1,11 @@ + + + netstandard2.1 + Optional secure built-in provider authentication flows for OpenGameAgent. + OpenGameAgent.Models.Auth.BuiltIn + + + + + + diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/ResolutionOverlayAuthentication.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/ResolutionOverlayAuthentication.cs new file mode 100644 index 0000000..059f5b6 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/ResolutionOverlayAuthentication.cs @@ -0,0 +1,53 @@ +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal sealed class ResolutionOverlayAuthentication : IGameProviderAuthentication +{ + private readonly IGameProviderAuthentication _inner; + private readonly IReadOnlyDictionary _headers; + + public ResolutionOverlayAuthentication( + IGameProviderAuthentication inner, + IReadOnlyDictionary headers) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _headers = new Dictionary( + headers ?? throw new ArgumentNullException(nameof(headers)), + StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyCollection Schemes => _inner.Schemes; + + public ValueTask CheckAsync(CancellationToken cancellationToken) => + _inner.CheckAsync(cancellationToken); + + public async ValueTask ResolveAsync(CancellationToken cancellationToken) + { + var resolution = await _inner.ResolveAsync(cancellationToken).ConfigureAwait(false); + if (resolution is null) + { + return null; + } + + var headers = new Dictionary(resolution.Headers, StringComparer.OrdinalIgnoreCase); + foreach (var pair in _headers) + { + headers[pair.Key] = pair.Value; + } + + return new GameProviderAuthResolution( + resolution.Credential, + resolution.Source, + resolution.BaseUrl, + headers, + resolution.Configuration); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + _inner.LoginAsync(scheme, interaction, cancellationToken); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + _inner.LogoutAsync(cancellationToken); +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/SecureLoopbackOAuth.cs b/src/OpenGameAgent.Models.Auth.BuiltIn/SecureLoopbackOAuth.cs new file mode 100644 index 0000000..6f5d21d --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/SecureLoopbackOAuth.cs @@ -0,0 +1,665 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace OpenGameAgent.Models.Auth.BuiltIn; + +internal enum LoopbackStatePlacement +{ + Query, + CallbackPath, +} + +internal sealed class SecureLoopbackOAuthOptions +{ + public SecureLoopbackOAuthOptions( + Uri authorizationEndpoint, + string redirectHost, + string callbackPath, + TimeSpan loginTimeout, + Func buildAuthorizationUri, + Func> exchangeAsync, + int port = 0, + LoopbackStatePlacement statePlacement = LoopbackStatePlacement.Query) + { + AuthorizationEndpoint = authorizationEndpoint; + RedirectHost = redirectHost; + CallbackPath = callbackPath; + LoginTimeout = loginTimeout; + BuildAuthorizationUri = buildAuthorizationUri; + ExchangeAsync = exchangeAsync; + Port = port; + StatePlacement = statePlacement; + } + + public Uri AuthorizationEndpoint { get; } + + public string RedirectHost { get; } + + public string CallbackPath { get; } + + public int Port { get; } + + public LoopbackStatePlacement StatePlacement { get; } + + public TimeSpan LoginTimeout { get; } + + public Func BuildAuthorizationUri { get; } + + public Func> ExchangeAsync { get; } +} + +internal static class SecureLoopbackOAuth +{ + private const int MaximumRequestBytes = 32_768; + private const int MaximumRequestTargetBytes = 8192; + private const int MaximumHeaders = 64; + private const int MaximumAttempts = 16; + + public static async ValueTask LoginAsync( + SecureLoopbackOAuthOptions options, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + Validate(options); + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var verifier = RandomToken(64); + var challenge = Base64Url(Sha256(Encoding.ASCII.GetBytes(verifier))); + var state = RandomToken(32); + var callbackPath = options.StatePlacement == LoopbackStatePlacement.CallbackPath + ? options.CallbackPath.TrimEnd('/') + "/" + state + : options.CallbackPath; + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lifetime.CancelAfter(options.LoginTimeout); + await using var listener = await LoopbackListener.StartAsync( + options.RedirectHost, + options.Port, + callbackPath, + options.StatePlacement == LoopbackStatePlacement.Query ? state : null, + lifetime.Token).ConfigureAwait(false); + var redirectUri = listener.RedirectUri; + var authorizationUri = options.BuildAuthorizationUri(redirectUri, challenge, state); + BoundedOAuthHttp.RequireHttps(authorizationUri, nameof(options.AuthorizationEndpoint)); + if (authorizationUri.AbsoluteUri.Length > 16_384) + { + throw new InvalidOperationException("The OAuth authorization URL exceeded its safety bound."); + } + + if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync( + $"Waiting for an authorization callback at {redirectUri}", + lifetime.Token).ConfigureAwait(false); + } + + if (interaction.OpenBrowserAsync is not null) + { + await interaction.OpenBrowserAsync(authorizationUri, lifetime.Token).ConfigureAwait(false); + } + else if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync(authorizationUri.AbsoluteUri, lifetime.Token).ConfigureAwait(false); + } + + using var promptCancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); + var callbackTask = listener.WaitForCallbackAsync(lifetime.Token); + Task? promptTask = null; + if (interaction.PromptAsync is not null) + { + promptTask = ParsePromptAsync( + interaction.PromptAsync, + redirectUri, + callbackPath, + state, + options.StatePlacement, + promptCancellation.Token); + } + + LoopbackAuthorizationResult result; + try + { + if (promptTask is null) + { + result = await callbackTask.ConfigureAwait(false); + } + else + { + var completed = await Task.WhenAny(callbackTask, promptTask).ConfigureAwait(false); + result = await completed.ConfigureAwait(false); + if (completed == callbackTask) + { + promptCancellation.Cancel(); + Observe(promptTask); + } + else + { + await listener.StopAsync().ConfigureAwait(false); + Observe(callbackTask); + } + } + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested && lifetime.IsCancellationRequested) + { + throw new TimeoutException("The OAuth login timed out.", exception); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (result.Error is not null) + { + throw new InvalidOperationException($"OAuth authorization was denied ({result.Error})."); + } + + try + { + var credential = await options.ExchangeAsync( + result.Code!, + verifier, + state, + redirectUri, + lifetime.Token).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return credential ?? throw new InvalidOperationException("The OAuth exchange returned no credential."); + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested && lifetime.IsCancellationRequested) + { + throw new TimeoutException("The OAuth login timed out.", exception); + } + } + + private static async Task ParsePromptAsync( + Func> prompt, + Uri redirectUri, + string callbackPath, + string state, + LoopbackStatePlacement placement, + CancellationToken cancellationToken) + { + var input = await prompt( + "Paste the complete OAuth callback URL.", + false, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(input) + || input.Length > 16_384 + || !Uri.TryCreate(input.Trim(), UriKind.Absolute, out var callback) + || callback.Scheme != Uri.UriSchemeHttp + || !string.Equals(callback.Host, redirectUri.Host, StringComparison.OrdinalIgnoreCase) + || callback.Port != redirectUri.Port + || !FixedTimeEquals(callback.AbsolutePath, callbackPath)) + { + throw new InvalidOperationException("The OAuth callback URL did not match the active loopback listener."); + } + + var query = ParseQuery(callback.Query); + if (placement == LoopbackStatePlacement.Query + && (!query.TryGetValue("state", out var returnedState) || !FixedTimeEquals(returnedState, state))) + { + throw new InvalidOperationException("The OAuth callback state did not match the active login."); + } + + if (query.TryGetValue("error", out var error)) + { + return new LoopbackAuthorizationResult(null, Bound(error, 4096)); + } + + if (!query.TryGetValue("code", out var code) || !IsBoundedValue(code, 65_536)) + { + throw new InvalidOperationException("The OAuth callback omitted its authorization code."); + } + + return new LoopbackAuthorizationResult(code, null); + } + + private static void Validate(SecureLoopbackOAuthOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + BoundedOAuthHttp.RequireHttps(options.AuthorizationEndpoint, nameof(options.AuthorizationEndpoint)); + if (options.RedirectHost is not ("127.0.0.1" or "localhost")) + { + throw new ArgumentException("Only an explicit IPv4 loopback host is supported.", nameof(options.RedirectHost)); + } + + if (options.Port is < 0 or > 65_535) + { + throw new ArgumentOutOfRangeException(nameof(options.Port)); + } + + if (string.IsNullOrWhiteSpace(options.CallbackPath) + || options.CallbackPath.Length > 1024 + || options.CallbackPath[0] != '/' + || options.CallbackPath.IndexOfAny(new[] { '?', '#', '\r', '\n', '\0' }) >= 0 + || options.CallbackPath.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException("A bounded absolute callback path is required.", nameof(options.CallbackPath)); + } + + if (options.LoginTimeout < TimeSpan.FromSeconds(10) || options.LoginTimeout > TimeSpan.FromMinutes(30)) + { + throw new ArgumentOutOfRangeException(nameof(options.LoginTimeout)); + } + + _ = options.BuildAuthorizationUri ?? throw new ArgumentException("An authorization URL builder is required."); + _ = options.ExchangeAsync ?? throw new ArgumentException("An authorization exchange is required."); + } + + private static async Task ReadCallbackAsync( + TcpClient client, + string expectedHost, + string expectedPath, + string? expectedState, + CancellationToken cancellationToken) + { + using (client) + using (var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + requestTimeout.CancelAfter(TimeSpan.FromSeconds(5)); + var request = await ReadRequestAsync(client.GetStream(), requestTimeout.Token).ConfigureAwait(false); + if (!string.Equals(request.Method, "GET", StringComparison.Ordinal) + || !string.Equals(request.Host, expectedHost, StringComparison.OrdinalIgnoreCase) + || request.HasBody) + { + await WriteResponseAsync(client, 400, "Invalid OAuth callback request.", requestTimeout.Token) + .ConfigureAwait(false); + throw new InvalidCallbackException(); + } + + if (!Uri.TryCreate("http://" + expectedHost + request.Target, UriKind.Absolute, out var target) + || !FixedTimeEquals(target.AbsolutePath, expectedPath)) + { + await WriteResponseAsync(client, 404, "OAuth callback route not found.", requestTimeout.Token) + .ConfigureAwait(false); + throw new InvalidCallbackException(); + } + + Dictionary query; + try + { + query = ParseQuery(target.Query); + } + catch (InvalidOperationException) + { + await WriteResponseAsync(client, 400, "Invalid OAuth callback query.", requestTimeout.Token) + .ConfigureAwait(false); + throw new InvalidCallbackException(); + } + + if (expectedState is not null + && (!query.TryGetValue("state", out var state) || !FixedTimeEquals(state, expectedState))) + { + await WriteResponseAsync(client, 400, "OAuth callback state mismatch.", requestTimeout.Token) + .ConfigureAwait(false); + throw new InvalidCallbackException(); + } + + if (query.TryGetValue("error", out var error)) + { + await WriteResponseAsync(client, 400, "OAuth authorization was denied.", requestTimeout.Token) + .ConfigureAwait(false); + return new LoopbackAuthorizationResult(null, Bound(error, 4096)); + } + + if (!query.TryGetValue("code", out var code) || !IsBoundedValue(code, 65_536)) + { + await WriteResponseAsync(client, 400, "OAuth callback omitted its code.", requestTimeout.Token) + .ConfigureAwait(false); + throw new InvalidCallbackException(); + } + + await WriteResponseAsync( + client, + 200, + "Authorization received. Return to the application to finish signing in.", + requestTimeout.Token).ConfigureAwait(false); + return new LoopbackAuthorizationResult(code, null); + } + } + + private static async Task ReadRequestAsync(Stream stream, CancellationToken cancellationToken) + { + using var buffer = new MemoryStream(); + var chunk = new byte[2048]; + while (buffer.Length < MaximumRequestBytes) + { + var read = await BoundedOAuthHttp.WaitAsync( + stream.ReadAsync(chunk, 0, chunk.Length, cancellationToken), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + buffer.Write(chunk, 0, read); + var bytes = buffer.GetBuffer(); + var count = checked((int)buffer.Length); + if (HeaderEnd(bytes, count) >= 0) + { + break; + } + } + + var length = checked((int)buffer.Length); + var headerEnd = HeaderEnd(buffer.GetBuffer(), length); + if (headerEnd < 0 || length > MaximumRequestBytes) + { + throw new InvalidCallbackException(); + } + + var bytesRead = buffer.ToArray(); + if (bytesRead.Take(headerEnd).Any(value => value is > 0x7f or 0)) + { + throw new InvalidCallbackException(); + } + + var headerText = Encoding.ASCII.GetString(bytesRead, 0, headerEnd); + var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None); + var requestLine = lines[0].Split(' '); + if (requestLine.Length != 3 + || requestLine[1].Length > MaximumRequestTargetBytes + || requestLine[2] is not ("HTTP/1.0" or "HTTP/1.1") + || lines.Length - 1 > MaximumHeaders) + { + throw new InvalidCallbackException(); + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in lines.Skip(1)) + { + if (line.Length == 0) + { + continue; + } + + var separator = line.IndexOf(':'); + if (separator <= 0 + || !headers.TryAdd(line.Substring(0, separator).Trim(), line.Substring(separator + 1).Trim())) + { + throw new InvalidCallbackException(); + } + } + + if (!headers.TryGetValue("Host", out var host) || !IsBoundedValue(host, 512)) + { + throw new InvalidCallbackException(); + } + + var hasBody = headers.ContainsKey("Transfer-Encoding") + || headers.TryGetValue("Content-Length", out var contentLength) + && !string.Equals(contentLength, "0", StringComparison.Ordinal); + return new HttpRequest(requestLine[0], requestLine[1], host, hasBody); + } + + private static async Task WriteResponseAsync( + TcpClient client, + int status, + string message, + CancellationToken cancellationToken) + { + var body = Encoding.UTF8.GetBytes(message); + var reason = status switch + { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + _ => "Error", + }; + var header = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {status} {reason}\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n" + + $"Content-Length: {body.Length}\r\n\r\n"); + var stream = client.GetStream(); + await BoundedOAuthHttp.WaitAsync( + stream.WriteAsync(header, 0, header.Length, cancellationToken), + cancellationToken).ConfigureAwait(false); + await BoundedOAuthHttp.WaitAsync( + stream.WriteAsync(body, 0, body.Length, cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + private static int HeaderEnd(byte[] bytes, int count) + { + for (var index = 0; index <= count - 4; index++) + { + if (bytes[index] == '\r' + && bytes[index + 1] == '\n' + && bytes[index + 2] == '\r' + && bytes[index + 3] == '\n') + { + return index; + } + } + + return -1; + } + + private static Dictionary ParseQuery(string query) + { + if (query.Length > 16_384) + { + throw new InvalidOperationException("The OAuth callback query exceeded its safety bound."); + } + + var result = new Dictionary(StringComparer.Ordinal); + var parts = query.TrimStart('?').Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 32) + { + throw new InvalidOperationException("The OAuth callback query contains too many fields."); + } + + foreach (var part in parts) + { + var pieces = part.Split(new[] { '=' }, 2); + string key; + string value; + try + { + key = Uri.UnescapeDataString(pieces[0]); + value = pieces.Length == 2 ? Uri.UnescapeDataString(pieces[1]) : string.Empty; + } + catch (UriFormatException exception) + { + throw new InvalidOperationException("The OAuth callback query is invalid.", exception); + } + + if (!IsBoundedValue(key, 256) || value.Length > 65_536 || !result.TryAdd(key, value)) + { + throw new InvalidOperationException("The OAuth callback query contains an invalid field."); + } + } + + return result; + } + + private static bool IsBoundedValue(string? value, int maximum) => + !string.IsNullOrWhiteSpace(value) + && value.Length <= maximum + && value.IndexOfAny(new[] { '\r', '\n', '\0' }) < 0; + + private static string Bound(string value, int maximum) => + IsBoundedValue(value, maximum) + ? value + : throw new InvalidOperationException("An OAuth callback field exceeded its safety bound."); + + private static string RandomToken(int byteCount) + { + var bytes = new byte[byteCount]; + using var random = RandomNumberGenerator.Create(); + random.GetBytes(bytes); + return Base64Url(bytes); + } + + private static byte[] Sha256(byte[] value) + { + using var hash = SHA256.Create(); + return hash.ComputeHash(value); + } + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + return leftBytes.Length == rightBytes.Length + && CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + private static void Observe(Task task) => + _ = task.ContinueWith( + completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + private sealed class LoopbackListener : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly string _expectedHost; + private readonly string _expectedPath; + private readonly string? _expectedState; + private int _stopped; + + private LoopbackListener( + TcpListener listener, + string expectedHost, + string expectedPath, + string? expectedState, + Uri redirectUri) + { + _listener = listener; + _expectedHost = expectedHost; + _expectedPath = expectedPath; + _expectedState = expectedState; + RedirectUri = redirectUri; + } + + public Uri RedirectUri { get; } + + public static Task StartAsync( + string redirectHost, + int requestedPort, + string path, + string? expectedState, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var listener = new TcpListener(IPAddress.Loopback, requestedPort); + listener.Start(8); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + var authority = redirectHost + ":" + endpoint.Port; + var redirect = new Uri("http://" + authority + path, UriKind.Absolute); + return Task.FromResult(new LoopbackListener(listener, authority, path, expectedState, redirect)); + } + + public async Task WaitForCallbackAsync(CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < MaximumAttempts; attempt++) + { + TcpClient client; + using (cancellationToken.Register(Stop)) + { + try + { + client = await BoundedOAuthHttp.WaitAsync( + _listener.AcceptTcpClientAsync(), + cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + } + + try + { + return await ReadCallbackAsync( + client, + _expectedHost, + _expectedPath, + _expectedState, + cancellationToken).ConfigureAwait(false); + } + catch (InvalidCallbackException) + { + } + } + + throw new InvalidOperationException("The OAuth loopback listener rejected too many invalid callbacks."); + } + + public Task StopAsync() + { + Stop(); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Stop(); + return default; + } + + private void Stop() + { + if (Interlocked.Exchange(ref _stopped, 1) == 0) + { + _listener.Stop(); + } + } + } + + private sealed class InvalidCallbackException : Exception + { + } + + private sealed class LoopbackAuthorizationResult + { + public LoopbackAuthorizationResult(string? code, string? error) + { + Code = code; + Error = error; + } + + public string? Code { get; } + + public string? Error { get; } + } + + private sealed class HttpRequest + { + public HttpRequest(string method, string target, string host, bool hasBody) + { + Method = method; + Target = target; + Host = host; + HasBody = hasBody; + } + + public string Method { get; } + + public string Target { get; } + + public string Host { get; } + + public bool HasBody { get; } + } +} diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json b/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json new file mode 100644 index 0000000..c4c96c2 --- /dev/null +++ b/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json @@ -0,0 +1,209 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.6" + } + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models.builtin": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Models": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openaicompatible": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs new file mode 100644 index 0000000..273625d --- /dev/null +++ b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs @@ -0,0 +1,1936 @@ +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.Providers.Anthropic; +using OpenGameAgent.Providers.Bedrock; +using OpenGameAgent.Providers.Google; +using OpenGameAgent.Providers.Mistral; +using OpenGameAgent.Providers.OpenAI; +using OpenGameAgent.Providers.OpenAICompatible; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Models.BuiltIn; + +public sealed class BuiltInGameModelRuntime +{ + private static readonly IReadOnlyCollection ApiIds = Array.AsReadOnly(new[] + { + BuiltInGameModelApis.AnthropicMessages, + BuiltInGameModelApis.AzureOpenAiResponses, + BuiltInGameModelApis.BedrockConverseStream, + BuiltInGameModelApis.GoogleGenerativeAi, + BuiltInGameModelApis.GoogleVertex, + BuiltInGameModelApis.MistralConversations, + BuiltInGameModelApis.OpenAiCodexResponses, + BuiltInGameModelApis.OpenAiCompletions, + BuiltInGameModelApis.OpenAiResponses, + }); + + private static readonly HashSet SupportedApiIds = new(ApiIds, StringComparer.Ordinal); + private readonly BuiltInGameModelRuntimeOptions _options; + + public BuiltInGameModelRuntime(BuiltInGameModelRuntimeOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + Directory = options.Directory ?? throw new ArgumentException("A model directory is required.", nameof(options)); + Catalog = options.Catalog ?? throw new ArgumentException("A model catalog is required.", nameof(options)); + if (options.GetEnvironmentVariable is null) + { + throw new ArgumentException("An environment-variable resolver is required.", nameof(options)); + } + + if (options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "The response observer timeout must be between 1 and 30000 milliseconds."); + } + + RegisterDirectory(); + } + + public GameModelDirectorySnapshot Directory { get; } + + public GameModelCatalog Catalog { get; } + + public static IReadOnlyCollection SupportedApis => ApiIds; + + public IModelProvider CreateProvider(string providerId) => + Catalog.CreateProvider(RequireId(providerId, nameof(providerId))); + + public IAsyncEnumerable StreamAsync( + string providerId, + ModelRequest request, + CancellationToken cancellationToken = default) => + StreamWithBoundaryAsync( + RequireId(providerId, nameof(providerId)), + request ?? throw new ArgumentNullException(nameof(request)), + cancellationToken); + + public async ValueTask CompleteAsync( + string providerId, + ModelRequest request, + CancellationToken cancellationToken = default) + { + await foreach (var streamEvent in StreamAsync(providerId, request, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + if (streamEvent.IsTerminal) + { + return streamEvent.Response + ?? throw new InvalidOperationException("A terminal model event omitted its response."); + } + } + + return Failure( + providerId, + api: null, + request.Model, + "The model stream ended without a terminal response.").Response!; + } + + private async IAsyncEnumerable StreamWithBoundaryAsync( + string providerId, + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var requestedApi = Catalog.GetModel(providerId, request.Model)?.Api; + + IAsyncEnumerator? enumerator = null; + Exception? enumerationSetupError = null; + try + { + enumerator = Catalog.CreateProvider(providerId).StreamAsync(request, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + enumerationSetupError = exception; + } + + if (enumerationSetupError is not null) + { + yield return Failure(providerId, requestedApi, request.Model, ErrorMessage(enumerationSetupError)); + yield break; + } + + var terminal = false; + var failed = false; + try + { + while (!terminal && !failed) + { + var moved = false; + ModelStreamEvent? current = null; + Exception? moveError = null; + try + { + moved = await enumerator!.MoveNextAsync().ConfigureAwait(false); + if (moved) + { + current = enumerator.Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + moveError = exception; + } + + if (moveError is not null) + { + failed = true; + yield return Failure(providerId, requestedApi, request.Model, ErrorMessage(moveError)); + continue; + } + + if (!moved) + { + failed = true; + yield return Failure( + providerId, + requestedApi, + request.Model, + "The provider stream ended without a terminal response."); + continue; + } + + if (current is null) + { + failed = true; + yield return Failure(providerId, requestedApi, request.Model, "The provider emitted a null event."); + continue; + } + + terminal = current.IsTerminal; + yield return current; + } + } + finally + { + try + { + await enumerator!.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Cleanup is best-effort. It must not replace a terminal response, + // an in-band provider failure, or the caller's cancellation. + } + } + } + + private async IAsyncEnumerable StreamRegisteredAsync( + string providerId, + ModelRequest request, + GameProviderAuthResolution? authentication, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var provider = Catalog.GetProvider(providerId)?.Descriptor + ?? throw new InvalidOperationException($"Unknown model provider '{providerId}'."); + var model = Catalog.GetModel(providerId, request.Model) + ?? throw new InvalidOperationException($"Unknown model '{providerId}/{request.Model}'."); + if (!SupportedApiIds.Contains(model.Api)) + { + throw new InvalidOperationException( + $"Model '{providerId}/{request.Model}' uses unsupported API '{model.Api}'."); + } + + var configuration = EnvironmentTransportConfiguration(model) + .Overlay(AuthenticationTransportConfiguration(authentication)); + if (_options.ProviderConfigurations.TryGetValue(providerId, out var configured)) + { + configuration = configuration.Overlay(ResolvedGameModelTransportConfiguration.Snapshot(configured)); + } + + if (_options.ResolveConfigurationAsync is not null) + { + var context = new GameModelTransportConfigurationContext(provider, model, request); + var resolved = await ProviderCallbackRunner.RunAsync( + token => _options.ResolveConfigurationAsync(context, token), + cancellationToken) + .ConfigureAwait(false); + configuration = configuration.Overlay(ResolvedGameModelTransportConfiguration.Snapshot(resolved)); + } + + ValidateConfiguration(configuration); + var compatibility = ModelCompatibility.Parse(model.CompatibilityJson); + var normalizedRequest = ApplyApiRequestConfiguration( + NormalizeRequest(request, model, compatibility), + model, + configuration); + var headers = MergeHeaders(model.Headers, configuration.Headers); + ValidateHeaders(model.Api, headers); + var implementation = CreateImplementation( + provider, + model, + configuration, + authentication, + compatibility, + headers); + await foreach (var streamEvent in implementation.StreamAsync(normalizedRequest, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return streamEvent; + } + } + + private static ResolvedGameModelTransportConfiguration AuthenticationTransportConfiguration( + GameProviderAuthResolution? authentication) + { + if (authentication is null) + { + return ResolvedGameModelTransportConfiguration.Empty; + } + + var configuration = new GameModelProviderTransportConfiguration + { + BaseUrl = authentication.BaseUrl, + }; + foreach (var pair in authentication.Headers) + { + configuration.Headers[pair.Key] = pair.Value; + } + + foreach (var pair in authentication.Configuration) + { + configuration.Options[pair.Key] = pair.Value; + } + + return ResolvedGameModelTransportConfiguration.Snapshot(configuration); + } + + private void RegisterDirectory() + { + var providerIds = Directory.Providers + .Select(provider => provider.ProviderId) + .ToHashSet(StringComparer.Ordinal); + var unknownAuthentication = _options.Authentications.Keys.FirstOrDefault(id => !providerIds.Contains(id)); + if (unknownAuthentication is not null) + { + throw new ArgumentException( + $"Authentication was configured for unknown provider '{unknownAuthentication}'.", + nameof(_options.Authentications)); + } + + var unknownTransport = _options.ProviderConfigurations.Keys.FirstOrDefault(id => !providerIds.Contains(id)); + if (unknownTransport is not null) + { + throw new ArgumentException( + $"Transport was configured for unknown provider '{unknownTransport}'.", + nameof(_options.ProviderConfigurations)); + } + + foreach (var provider in Directory.Providers) + { + var models = Array.AsReadOnly(Directory.GetModels(provider.ProviderId) + .Where(IsExecutableModel) + .Select(ExecutableDescriptor) + .ToArray()); + var authentication = _options.Authentications.TryGetValue(provider.ProviderId, out var configured) + ? configured + : _options.CreateAuthentication?.Invoke(provider, models) + ?? CreateDefaultAuthentication(provider, models); + if (authentication is null) + { + throw new InvalidOperationException( + $"The authentication factory returned null for provider '{provider.ProviderId}'."); + } + + var registration = new GameModelProviderRegistration( + provider, + new DirectoryDispatchProvider(this, provider.ProviderId), + authentication, + models, + stream: (request, resolution, cancellationToken) => StreamRegisteredAsync( + provider.ProviderId, + request, + resolution, + cancellationToken), + catalogVersion: Directory.Version); + Catalog.Register(registration, _options.ReplaceExistingProviders); + } + } + + private static GameModelDescriptor ExecutableDescriptor(GameModelDescriptor model) + { + const GameModelInputCapabilities executableInput = + GameModelInputCapabilities.Text + | GameModelInputCapabilities.Image + | GameModelInputCapabilities.StructuredData; + const GameModelOutputCapabilities executableOutput = + GameModelOutputCapabilities.Text + | GameModelOutputCapabilities.StructuredData + | GameModelOutputCapabilities.ToolCalls + | GameModelOutputCapabilities.Reasoning; + var input = model.InputCapabilities & executableInput; + var output = model.OutputCapabilities & executableOutput; + if (input == model.InputCapabilities && output == model.OutputCapabilities) + { + return model; + } + + return new GameModelDescriptor( + model.ProviderId, + model.ModelId, + model.DisplayName, + model.ContextWindowTokens, + model.MaximumOutputTokens, + input, + output, + model.ReasoningLevels, + model.Cost, + model.Metadata, + model.ReasoningLevelValues, + model.Api, + model.BaseUrl, + model.SamplingParametersJson, + model.Headers, + model.CompatibilityJson); + } + + private static bool IsExecutableModel(GameModelDescriptor model) => + !(model.Api == BuiltInGameModelApis.OpenAiResponses + && model.ModelId.Contains("realtime", StringComparison.OrdinalIgnoreCase)); + + private IGameProviderAuthentication CreateDefaultAuthentication( + GameProviderDescriptor provider, + IReadOnlyList models) + { + var apiIds = models.Select(model => model.Api).Distinct(StringComparer.Ordinal).ToArray(); + var onlyCodex = apiIds.Length > 0 + && apiIds.All(api => api == BuiltInGameModelApis.OpenAiCodexResponses); + var directorySources = onlyCodex + ? Array.Empty>() + : DirectoryCredentialSources(provider, apiIds); + if (provider.IsLocal && !onlyCodex + || apiIds.Length > 0 + && apiIds.All(api => api == BuiltInGameModelApis.BedrockConverseStream)) + { + return new StaticGameProviderAuthentication(configured: true, source: "ambient"); + } + + var variables = directorySources + .Concat(apiIds.SelectMany(api => DefaultCredentialSources(provider.ProviderId, api))) + .Concat(CodexCredentialSources(provider.ProviderId, apiIds)) + .GroupBy(source => source.Key, StringComparer.Ordinal) + .Select(group => group.First()) + .ToArray(); + return new EnvironmentChainAuthentication( + variables, + _options.GetEnvironmentVariable, + apiIds.Contains(BuiltInGameModelApis.GoogleVertex, StringComparer.Ordinal) + ? () => HasVertexAmbientConfiguration(provider.ProviderId) + : null); + } + + private static IReadOnlyList> DirectoryCredentialSources( + GameProviderDescriptor provider, + IReadOnlyCollection apiIds) + { + if (!provider.Metadata.TryGetValue( + BuiltInGameModelConfigurationKeys.EnvironmentVariablesMetadata, + out var declared)) + { + return Array.Empty>(); + } + + var variables = declared.Split(',').Select(value => value.Trim()).ToArray(); + if (variables.Length is < 1 or > 256 + || variables.Any(variable => !IsEnvironmentVariableName(variable))) + { + throw new ArgumentException( + $"Provider '{provider.ProviderId}' declares invalid environment variable metadata."); + } + + var kind = apiIds.All(api => api == BuiltInGameModelApis.GoogleVertex) + ? GameCredentialKind.BearerToken + : GameCredentialKind.ApiKey; + return Array.AsReadOnly(variables + .Where(IsCredentialEnvironmentVariable) + .Distinct(StringComparer.Ordinal) + .Select(variable => new KeyValuePair(variable, kind)) + .ToArray()); + } + + private static bool IsEnvironmentVariableName(string value) => + value.Length is > 0 and <= 256 + && (IsAsciiLetter(value[0]) || value[0] == '_') + && value.All(character => IsAsciiLetter(character) || character is >= '0' and <= '9' || character == '_'); + + private static bool IsAsciiLetter(char value) => + value is >= 'a' and <= 'z' or >= 'A' and <= 'Z'; + + private static bool IsCredentialEnvironmentVariable(string value) => + value.EndsWith("_API_KEY", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("_API_TOKEN", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("_ACCESS_TOKEN", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("_AUTH_TOKEN", StringComparison.OrdinalIgnoreCase) + || value.Contains("_BEARER_TOKEN", StringComparison.OrdinalIgnoreCase) + || value.EndsWith("_TOKEN", StringComparison.OrdinalIgnoreCase); + + private static IEnumerable> DefaultCredentialSources( + string providerId, + string api) => api switch + { + BuiltInGameModelApis.AnthropicMessages => + Sources(GameCredentialKind.ApiKey, "ANTHROPIC_API_KEY"), + BuiltInGameModelApis.AzureOpenAiResponses => + Sources(GameCredentialKind.ApiKey, "AZURE_OPENAI_API_KEY"), + BuiltInGameModelApis.GoogleGenerativeAi => + Sources(GameCredentialKind.ApiKey, "GEMINI_API_KEY", "GOOGLE_API_KEY"), + BuiltInGameModelApis.GoogleVertex => + Sources(GameCredentialKind.ApiKey, "GOOGLE_CLOUD_API_KEY") + .Concat(Sources( + GameCredentialKind.BearerToken, + "GOOGLE_VERTEX_ACCESS_TOKEN", + "GOOGLE_OAUTH_ACCESS_TOKEN")), + BuiltInGameModelApis.MistralConversations => + Sources(GameCredentialKind.ApiKey, "MISTRAL_API_KEY"), + BuiltInGameModelApis.OpenAiResponses => + Sources(GameCredentialKind.ApiKey, "OPENAI_API_KEY"), + BuiltInGameModelApis.OpenAiCodexResponses => + Array.Empty>(), + BuiltInGameModelApis.OpenAiCompletions => + Sources( + GameCredentialKind.ApiKey, + EnvironmentPrefix(providerId) + "_API_KEY", + "OPENAI_COMPATIBLE_API_KEY"), + _ => Array.Empty>(), + }; + + private static IReadOnlyList> Sources( + GameCredentialKind kind, + params string[] variables) => + Array.AsReadOnly(variables + .Select(variable => new KeyValuePair(variable, kind)) + .ToArray()); + + private IReadOnlyList> CodexCredentialSources( + string providerId, + IReadOnlyCollection apiIds) + { + if (!apiIds.Contains(BuiltInGameModelApis.OpenAiCodexResponses, StringComparer.Ordinal) + || !_options.ProviderConfigurations.TryGetValue(providerId, out var configured) + || !configured.Options.TryGetValue( + BuiltInGameModelConfigurationKeys.OpenAiCodexEnvironmentVariable, + out var variable) + || string.IsNullOrWhiteSpace(variable)) + { + return Array.Empty>(); + } + + variable = variable.Trim(); + if (!IsEnvironmentVariableName(variable)) + { + throw new ArgumentException( + $"Provider '{providerId}' declares an invalid Codex environment variable.", + nameof(_options.ProviderConfigurations)); + } + + return Sources(GameCredentialKind.OAuth, variable); + } + + private ResolvedGameModelTransportConfiguration EnvironmentTransportConfiguration( + GameModelDescriptor model) + { + var configuration = new GameModelProviderTransportConfiguration(); + if (model.Api == BuiltInGameModelApis.GoogleVertex) + { + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.GoogleProject, + "GOOGLE_VERTEX_PROJECT", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT"); + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.GoogleLocation, + "GOOGLE_VERTEX_LOCATION", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_REGION"); + } + else if (model.Api == BuiltInGameModelApis.AzureOpenAiResponses) + { + AddEnvironmentBaseUrl(configuration, "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT"); + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.AzureApiVersion, + "AZURE_OPENAI_API_VERSION"); + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.AzureDeploymentName, + "AZURE_OPENAI_DEPLOYMENT_NAME"); + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.AzureResourceName, + "AZURE_OPENAI_RESOURCE_NAME"); + } + else if (model.Api == BuiltInGameModelApis.BedrockConverseStream) + { + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.AwsRegion, + "AWS_REGION", + "AWS_DEFAULT_REGION"); + AddEnvironmentOption( + configuration, + BuiltInGameModelConfigurationKeys.AwsProfile, + "AWS_PROFILE"); + } + + return ResolvedGameModelTransportConfiguration.Snapshot(configuration); + } + + private void AddEnvironmentBaseUrl( + GameModelProviderTransportConfiguration configuration, + params string[] environmentNames) + { + var value = EnvironmentValue(environmentNames); + if (value is null) + { + return; + } + + if (!Uri.TryCreate(value.Trim(), UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException("An Azure OpenAI endpoint environment variable is invalid."); + } + + configuration.BaseUrl = endpoint; + } + + private bool HasVertexAmbientConfiguration(string providerId) + { + if (_options.VertexApplicationDefaultCredential is null) + { + return false; + } + + if (_options.ProviderConfigurations.TryGetValue(providerId, out var configured) + && (configured.BaseUrl is not null + || HasOption(configured, BuiltInGameModelConfigurationKeys.GoogleProject, "GOOGLE_VERTEX_PROJECT", "GOOGLE_CLOUD_PROJECT") + && HasOption(configured, BuiltInGameModelConfigurationKeys.GoogleLocation, "GOOGLE_VERTEX_LOCATION", "GOOGLE_CLOUD_LOCATION"))) + { + return true; + } + + return EnvironmentValue("GOOGLE_VERTEX_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT") is not null + && EnvironmentValue("GOOGLE_VERTEX_LOCATION", "GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_REGION") is not null; + } + + private static bool HasOption( + GameModelProviderTransportConfiguration configuration, + params string[] keys) => + keys.Any(key => configuration.Options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)); + + private IModelProvider CreateImplementation( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + return model.Api switch + { + BuiltInGameModelApis.AnthropicMessages => CreateAnthropic(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.AzureOpenAiResponses => CreateAzureOpenAi(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.BedrockConverseStream => CreateBedrock(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.GoogleGenerativeAi => CreateGoogle(provider, model, configuration, authentication, compatibility, headers, GoogleApiFlavor.Gemini), + BuiltInGameModelApis.GoogleVertex => CreateGoogle(provider, model, configuration, authentication, compatibility, headers, GoogleApiFlavor.Vertex), + BuiltInGameModelApis.MistralConversations => CreateMistral(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.OpenAiCodexResponses => CreateOpenAiCodex(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.OpenAiCompletions => CreateOpenAiCompatible(provider, model, configuration, authentication, compatibility, headers), + BuiltInGameModelApis.OpenAiResponses => CreateOpenAi(provider, model, configuration, authentication, compatibility, headers), + _ => throw new InvalidOperationException($"Unsupported model API '{model.Api}'."), + }; + } + + private IModelProvider CreateAnthropic( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var endpoint = Endpoint( + ResolveBaseUrl(model, configuration), + "https://api.anthropic.com/v1", + "messages"); + var credential = authentication?.Credential; + var providerHeaders = new Dictionary(headers, StringComparer.OrdinalIgnoreCase); + var apiKey = credential?.Secret; + if (HasHeaderValue(providerHeaders, "Authorization") || HasHeaderValue(providerHeaders, "x-api-key")) + { + apiKey = null; + } + else if (credential?.Kind is GameCredentialKind.BearerToken + or GameCredentialKind.OAuth + or GameCredentialKind.DeveloperHostedToken) + { + providerHeaders["Authorization"] = "Bearer " + credential.Secret; + apiKey = null; + } + + var options = new AnthropicMessagesProviderOptions(_options.HttpClient, endpoint) + { + ApiKey = apiKey, + ProviderId = provider.ProviderId, + ApiId = model.Api, + AllowInsecureHttp = AllowsInsecureEndpoint(endpoint), + SupportsEagerToolInputStreaming = compatibility.SupportsEagerToolInputStreaming ?? true, + SupportsLongCacheRetention = compatibility.SupportsLongCacheRetention ?? true, + SendSessionAffinityHeaders = compatibility.SendSessionAffinityHeaders ?? false, + SupportsCacheControlOnTools = compatibility.SupportsCacheControlOnTools ?? true, + SupportsTemperature = compatibility.SupportsTemperature ?? true, + ForceAdaptiveThinking = compatibility.ForceAdaptiveThinking ?? false, + AllowEmptyThinkingSignature = compatibility.AllowEmptySignature ?? false, + SupportsStrictTools = compatibility.SupportsStrictTools ?? false, + SupportsToolReferences = compatibility.SupportsToolReferences ?? false, + InterleavedThinking = compatibility.Interleaved ?? true, + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + foreach (var pair in providerHeaders) + { + options.Headers[pair.Key] = pair.Value; + } + return new AnthropicMessagesProvider(options); + } + + private IModelProvider CreateAzureOpenAi( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var hasExplicitAuthentication = HasHeaderValue(headers, "api-key") + || HasHeaderValue(headers, "Authorization"); + var credential = authentication?.Credential; + if (!hasExplicitAuthentication + && credential?.Kind is not (GameCredentialKind.ApiKey or GameCredentialKind.DeveloperHostedToken)) + { + throw new InvalidOperationException( + "Azure OpenAI Responses requires an API key or an explicit authentication header."); + } + + var apiVersion = RequireBoundedConfigurationValue( + Option(configuration, BuiltInGameModelConfigurationKeys.AzureApiVersion) ?? "v1", + 256, + "Azure OpenAI API version"); + var resourceName = Option(configuration, BuiltInGameModelConfigurationKeys.AzureResourceName); + var configuredEndpoint = ResolveBaseUrl(model, configuration); + OpenAIResponsesProviderOptions options; + if (configuredEndpoint is not null) + { + options = AzureOpenAIResponses.CreateOptions( + _options.HttpClient, + configuredEndpoint.OriginalString, + hasExplicitAuthentication ? null : credential!.Secret, + apiVersion); + } + else if (resourceName is not null) + { + resourceName = RequireBoundedConfigurationValue( + resourceName, + 256, + "Azure OpenAI resource name"); + options = AzureOpenAIResponses.CreateOptionsForResource( + _options.HttpClient, + resourceName, + hasExplicitAuthentication ? null : credential!.Secret, + apiVersion); + } + else + { + throw new InvalidOperationException( + "Azure OpenAI Responses requires a base URL or Azure resource name."); + } + + options.ProviderId = provider.ProviderId; + options.ApiId = model.Api; + options.AllowInsecureHttp = AllowsInsecureEndpoint(options.Endpoint); + options.SupportsDeveloperRole = compatibility.SupportsDeveloperRole ?? options.SupportsDeveloperRole; + options.SupportsStrictTools = compatibility.SupportsStrictMode ?? options.SupportsStrictTools; + options.SupportsGrammarTools = compatibility.SupportsOpenAiGrammarTools ?? false; + options.SupportsAdditionalTools = compatibility.SupportsAdditionalTools ?? false; + options.SupportsToolSearch = compatibility.SupportsToolSearch ?? false; + options.SupportsExplicitPromptCacheMode = compatibility.SupportsExplicitPromptCacheMode ?? false; + options.SupportsLongCacheRetention = false; + options.SessionAffinityFormat = ParseOpenAiSessionAffinityFormat(compatibility.SessionAffinityFormat); + options.ResponseObserver = _options.ResponseObserver; + options.ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds; + CopyNullableHeaders(headers, options.Headers); + return new OpenAIResponsesProvider(options); + } + + private IModelProvider CreateBedrock( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var credential = authentication?.Credential; + string? secretAccessKey = null; + var hasSecretAccessKey = credential is not null + && credential.Metadata.TryGetValue( + BuiltInGameModelConfigurationKeys.AwsSecretAccessKeyMetadata, + out secretAccessKey); + var options = new BedrockConverseProviderOptions + { + ProviderId = provider.ProviderId, + ApiId = model.Api, + Transport = configuration.BedrockTransport, + Region = Option(configuration, BuiltInGameModelConfigurationKeys.AwsRegion), + Profile = Option(configuration, BuiltInGameModelConfigurationKeys.AwsProfile), + ServiceUrl = ResolveBaseUrl(model, configuration)?.OriginalString, + AllowInsecureHttp = _options.AllowInsecureHttp, + AccessKeyId = hasSecretAccessKey ? credential!.Secret : null, + SecretAccessKey = hasSecretAccessKey ? secretAccessKey : null, + SessionToken = credential?.Metadata.TryGetValue( + BuiltInGameModelConfigurationKeys.AwsSessionTokenMetadata, + out var sessionToken) == true + ? sessionToken + : null, + BearerToken = credential is not null && !hasSecretAccessKey ? credential.Secret : null, + SkipAuthentication = BooleanOption( + configuration, + BuiltInGameModelConfigurationKeys.AwsSkipAuthentication, + defaultValue: false), + SupportsStrictTools = compatibility.SupportsStrictMode ?? false, + InterleavedThinking = compatibility.Interleaved ?? true, + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + CopyNullableHeaders(headers, options.Headers); + return new BedrockConverseProvider(options); + } + + private IModelProvider CreateGoogle( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers, + GoogleApiFlavor flavor) + { + var endpoint = flavor == GoogleApiFlavor.Vertex + ? VertexEndpoint(model, configuration) + : GoogleEndpoint(ResolveBaseUrl(model, configuration)); + var credential = authentication?.Credential; + var hasExplicitAuthentication = HasHeaderValue(headers, "Authorization") + || HasHeaderValue(headers, "x-goog-api-key"); + var placement = hasExplicitAuthentication || credential is null + ? flavor == GoogleApiFlavor.Vertex + && !hasExplicitAuthentication + && _options.VertexApplicationDefaultCredential is not null + ? GoogleCredentialPlacement.BearerToken + : GoogleCredentialPlacement.None + : credential.Kind is GameCredentialKind.BearerToken + or GameCredentialKind.OAuth + or GameCredentialKind.DeveloperHostedToken + ? GoogleCredentialPlacement.BearerToken + : GoogleCredentialPlacement.ApiKeyHeader; + var options = new GoogleGenerativeProviderOptions(_options.HttpClient, endpoint, flavor) + { + ProviderId = provider.ProviderId, + ApiId = model.Api, + Credential = hasExplicitAuthentication ? null : credential?.Secret, + GetCredentialAsync = flavor == GoogleApiFlavor.Vertex + && !hasExplicitAuthentication + && credential is null + ? _options.VertexApplicationDefaultCredential + : null, + CredentialPlacement = placement, + SupportsImages = model.InputCapabilities.HasFlag(GameModelInputCapabilities.Image), + UseLegacyOpenApiToolSchemas = compatibility.UseLegacyOpenApiToolSchemas ?? false, + AllowInsecureHttp = AllowsInsecureEndpoint(endpoint), + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + CopyNullableHeaders(headers, options.Headers); + return new GoogleGenerativeProvider(options); + } + + private IModelProvider CreateMistral( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var endpoint = Endpoint( + ResolveBaseUrl(model, configuration), + "https://api.mistral.ai/v1", + "chat/completions"); + var credential = BearerValue(headers) ?? authentication?.Credential?.Secret; + var options = new MistralConversationsProviderOptions(_options.HttpClient, endpoint) + { + ApiKey = credential, + ProviderId = provider.ProviderId, + ApiId = model.Api, + SupportsImages = model.InputCapabilities.HasFlag(GameModelInputCapabilities.Image), + ReasoningMode = ParseMistralReasoningMode(compatibility.ReasoningMode), + AllowInsecureHttp = AllowsInsecureEndpoint(endpoint), + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + CopyNullableHeaders(headers, options.Headers); + return new MistralConversationsProvider(options); + } + + private IModelProvider CreateOpenAiCompatible( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var endpoint = Endpoint( + ResolveBaseUrl(model, configuration), + "https://api.openai.com/v1", + "chat/completions"); + var apiKeyHeader = Option(configuration, BuiltInGameModelConfigurationKeys.AuthenticationHeader) + ?? (string.Equals(provider.ProviderId, "cloudflare-ai-gateway", StringComparison.Ordinal) + ? "cf-aig-authorization" + : "Authorization"); + var options = new OpenAICompatibleProviderOptions(_options.HttpClient, endpoint) + { + ApiKey = HasHeaderValue(headers, apiKeyHeader) ? null : authentication?.Credential?.Secret, + ProviderId = provider.ProviderId, + ApiId = model.Api, + ApiKeyHeader = apiKeyHeader, + ApiKeyScheme = Option(configuration, BuiltInGameModelConfigurationKeys.AuthenticationScheme) + ?? "Bearer", + AllowInsecureHttp = AllowsInsecureEndpoint(endpoint), + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + if (!string.IsNullOrWhiteSpace(compatibility.InterleavedField) + && !options.ReasoningDeltaFields.Contains(compatibility.InterleavedField!, StringComparer.Ordinal)) + { + options.ReasoningDeltaFields.Insert(0, compatibility.InterleavedField!); + } + + options.Protocol.SupportsStore = compatibility.SupportsStore ?? true; + options.Protocol.SupportsDeveloperRole = compatibility.SupportsDeveloperRole ?? true; + options.Protocol.SupportsReasoningEffort = compatibility.SupportsReasoningEffort ?? true; + options.Protocol.SupportsUsageInStreaming = compatibility.SupportsUsageInStreaming ?? true; + options.Protocol.SupportsFinishReason = compatibility.SupportsFinishReason ?? true; + options.Protocol.MaxTokensField = ParseMaxTokensField(compatibility.MaxTokensField); + options.Protocol.RequiresToolResultName = compatibility.RequiresToolResultName ?? false; + options.Protocol.RequiresAssistantAfterToolResult = compatibility.RequiresAssistantAfterToolResult ?? false; + options.Protocol.RequiresThinkingAsText = compatibility.RequiresThinkingAsText ?? false; + options.Protocol.RequiresReasoningContentOnAssistantMessages = + compatibility.RequiresReasoningContentOnAssistantMessages ?? false; + options.Protocol.ThinkingFormat = ParseThinkingFormat(compatibility.ThinkingFormat); + options.Protocol.ZaiToolStream = compatibility.ZaiToolStream ?? false; + options.Protocol.SupportsThinkingTokenBudget = compatibility.SupportsThinkingTokenBudget ?? false; + options.Protocol.SupportsStrictMode = compatibility.SupportsStrictMode ?? true; + options.Protocol.SupportsGrammarTools = compatibility.SupportsOpenAiGrammarTools ?? false; + options.Protocol.CacheControlFormat = ParseCacheControlFormat(compatibility.CacheControlFormat); + options.Protocol.SendSessionAffinityHeaders = compatibility.SendSessionAffinityHeaders ?? false; + options.Protocol.SessionAffinityFormat = ParseCompatibleSessionAffinityFormat( + compatibility.SessionAffinityFormat); + options.Protocol.DeferredToolsMode = ParseDeferredToolsMode(compatibility.DeferredToolsMode); + options.Protocol.SupportsLongCacheRetention = compatibility.SupportsLongCacheRetention ?? true; + options.Protocol.ChatTemplateArgumentsJson = compatibility.ChatTemplateArgumentsJson; + options.Protocol.ChatTemplateKeywordArgumentsJson = compatibility.ChatTemplateKeywordArgumentsJson; + + CopyNullableHeaders(headers, options.Headers); + return new OpenAICompatibleProvider(options); + } + + private IModelProvider CreateOpenAiCodex( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var explicitBearer = BearerValue(headers); + var credential = authentication?.Credential; + if (explicitBearer is null + && credential?.Kind is not (GameCredentialKind.OAuth + or GameCredentialKind.BearerToken + or GameCredentialKind.DeveloperHostedToken)) + { + throw new InvalidOperationException( + "OpenAI Codex Responses requires an OAuth or bearer access token; API keys are not accepted."); + } + + var endpoint = Endpoint( + ResolveBaseUrl(model, configuration), + "https://chatgpt.com/backend-api/codex", + "responses"); + var options = OpenAICodexResponses.CreateOptions( + _options.HttpClient, + explicitBearer ?? credential!.Secret, + endpoint, + compatibility.SupportsAdditionalTools ?? true, + compatibility.SupportsToolSearch ?? true); + options.ProviderId = provider.ProviderId; + options.ApiId = model.Api; + options.AllowInsecureHttp = AllowsInsecureEndpoint(endpoint); + options.SupportsStrictTools = compatibility.SupportsStrictMode ?? options.SupportsStrictTools; + options.SupportsGrammarTools = compatibility.SupportsOpenAiGrammarTools ?? options.SupportsGrammarTools; + options.SupportsExplicitPromptCacheMode = compatibility.SupportsExplicitPromptCacheMode ?? false; + options.SupportsLongCacheRetention = false; + options.ResponseObserver = _options.ResponseObserver; + options.ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds; + CopyNullableHeaders(headers, options.Headers); + + var configuredAccountId = headers.TryGetValue("chatgpt-account-id", out var accountHeader) + && accountHeader is not null + ? accountHeader + : Option(configuration, BuiltInGameModelConfigurationKeys.OpenAiCodexAccountId) + ?? (credential?.Metadata.TryGetValue( + BuiltInGameModelConfigurationKeys.OpenAiCodexAccountId, + out var accountMetadata) == true + ? accountMetadata + : null); + if (configuredAccountId is not null) + { + options.Headers["chatgpt-account-id"] = RequireBoundedConfigurationValue( + configuredAccountId, + 512, + "OpenAI Codex account ID"); + } + + return new OpenAIResponsesProvider(options); + } + + private IModelProvider CreateOpenAi( + GameProviderDescriptor provider, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration, + GameProviderAuthResolution? authentication, + ModelCompatibility compatibility, + IReadOnlyDictionary headers) + { + var credential = authentication?.Credential; + var authenticationStyle = ParseOpenAiAuthenticationStyle(configuration, credential, headers); + var endpoint = Endpoint( + ResolveBaseUrl(model, configuration), + "https://api.openai.com/v1", + "responses"); + var options = new OpenAIResponsesProviderOptions(_options.HttpClient, endpoint) + { + ApiKey = credential?.Secret, + ProviderId = provider.ProviderId, + ApiId = model.Api, + AuthenticationStyle = authenticationStyle, + ApiKeyHeaderName = Option(configuration, BuiltInGameModelConfigurationKeys.AuthenticationHeader) + ?? "api-key", + AllowInsecureHttp = AllowsInsecureEndpoint(endpoint), + SupportsDeveloperRole = compatibility.SupportsDeveloperRole ?? true, + SupportsStrictTools = compatibility.SupportsStrictMode ?? false, + SupportsGrammarTools = compatibility.SupportsOpenAiGrammarTools ?? false, + SupportsAdditionalTools = compatibility.SupportsAdditionalTools ?? false, + SupportsToolSearch = compatibility.SupportsToolSearch ?? false, + SupportsExplicitPromptCacheMode = compatibility.SupportsExplicitPromptCacheMode ?? false, + SupportsLongCacheRetention = compatibility.SupportsLongCacheRetention ?? true, + SessionAffinityFormat = ParseOpenAiSessionAffinityFormat(compatibility.SessionAffinityFormat), + ResponseObserver = _options.ResponseObserver, + ResponseObserverTimeoutMilliseconds = _options.ResponseObserverTimeoutMilliseconds, + }; + CopyNullableHeaders(headers, options.Headers); + return new OpenAIResponsesProvider(options); + } + + private string? EnvironmentValue(params string[] names) + { + foreach (var name in names) + { + var value = _options.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private void AddEnvironmentOption( + GameModelProviderTransportConfiguration configuration, + string key, + params string[] environmentNames) + { + var value = EnvironmentValue(environmentNames); + if (value is not null) + { + configuration.Options[key] = value; + } + } + + private static ModelRequest NormalizeRequest( + ModelRequest request, + GameModelDescriptor model, + ModelCompatibility compatibility) + { + var parameters = request.Parameters.Clone(); + parameters.SamplingParametersJson ??= model.SamplingParametersJson; + if (model.MaximumOutputTokens > 0) + { + parameters.MaxOutputTokens = parameters.MaxOutputTokens is { } requested + ? Math.Min(requested, model.MaximumOutputTokens) + : model.MaximumOutputTokens; + } + + if (compatibility.SupportsTemperature == false) + { + parameters.Temperature = null; + } + + if (model.Api == BuiltInGameModelApis.BedrockConverseStream) + { + ApplyBedrockSamplingExtensions(parameters); + } + + return new ModelRequest( + request.Model, + request.SystemPrompt, + NormalizeMessages(request.Messages, model.InputCapabilities), + request.Tools, + parameters, + request.SessionId, + request.RunId, + request.Turn); + } + + private static ModelRequest ApplyApiRequestConfiguration( + ModelRequest request, + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration) + { + if (model.Api != BuiltInGameModelApis.AzureOpenAiResponses) + { + return request; + } + + var deploymentName = Option(configuration, BuiltInGameModelConfigurationKeys.AzureDeploymentName); + if (deploymentName is null) + { + return request; + } + + deploymentName = RequireBoundedConfigurationValue( + deploymentName, + 512, + "Azure OpenAI deployment name"); + return new ModelRequest( + deploymentName, + request.SystemPrompt, + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn); + } + + private static IReadOnlyList NormalizeMessages( + IReadOnlyList messages, + GameModelInputCapabilities capabilities) + { + var result = new AgentMessage[messages.Count]; + for (var index = 0; index < messages.Count; index++) + { + var message = messages[index]; + if (message.Role is not AgentRole.User and not AgentRole.Tool) + { + result[index] = message; + continue; + } + + var content = message.Content.Select(part => NormalizeInputPart(part, capabilities)).ToArray(); + result[index] = content.SequenceEqual(message.Content) + ? message + : CopyMessage(message, content); + } + + return Array.AsReadOnly(result); + } + + private static AgentContent NormalizeInputPart( + AgentContent content, + GameModelInputCapabilities capabilities) + { + if (content is BinaryContent binary && !Supports(binary.MediaKind, capabilities)) + { + return UnsupportedMediaPlaceholder(binary.MediaKind); + } + + if (content is ResourceContent resource + && MediaKind(resource.MediaType) is { } mediaKind + && !Supports(mediaKind, capabilities)) + { + return UnsupportedMediaPlaceholder(mediaKind); + } + + if (content is JsonContent json + && !capabilities.HasFlag(GameModelInputCapabilities.StructuredData)) + { + return new TextContent(json.Json); + } + + return content; + } + + private static bool Supports(AgentMediaKind kind, GameModelInputCapabilities capabilities) => kind switch + { + AgentMediaKind.Image => capabilities.HasFlag(GameModelInputCapabilities.Image), + AgentMediaKind.Audio => capabilities.HasFlag(GameModelInputCapabilities.Audio), + AgentMediaKind.Video => capabilities.HasFlag(GameModelInputCapabilities.Video), + AgentMediaKind.File => false, + _ => false, + }; + + private static AgentMediaKind? MediaKind(string mediaType) + { + if (mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return AgentMediaKind.Image; + } + + if (mediaType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return AgentMediaKind.Audio; + } + + if (mediaType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + { + return AgentMediaKind.Video; + } + + return null; + } + + private static TextContent UnsupportedMediaPlaceholder(AgentMediaKind kind) => + new($"[{kind.ToString().ToLowerInvariant()} omitted: model does not support this input]"); + + private static AgentMessage CopyMessage(AgentMessage message, IReadOnlyList content) => new( + message.Role, + content, + message.Timestamp, + customRole: message.CustomRole, + toolCallId: message.ToolCallId, + toolName: message.ToolName, + isError: message.IsError, + detailsJson: message.DetailsJson, + metadata: message.Metadata, + model: message.Model, + stopReason: message.StopReason, + usage: message.Usage, + errorMessage: message.ErrorMessage, + provider: message.Provider, + api: message.Api, + responseModel: message.ResponseModel, + responseId: message.ResponseId, + rawStopReason: message.RawStopReason, + endTurn: message.EndTurn, + diagnostics: message.Role == AgentRole.Assistant ? message.Diagnostics : null, + deferred: message.Deferred, + addedToolNames: message.Role == AgentRole.Tool ? message.AddedToolNames : null); + + private static void ApplyBedrockSamplingExtensions(ModelParameters parameters) + { + if (parameters.SamplingParametersJson is null) + { + return; + } + + using var document = JsonDocument.Parse(parameters.SamplingParametersJson); + var extensions = new Dictionary(parameters.Extensions, StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Name is "topP" or "top_p" or "stopSequences" or "stop_sequences") + { + continue; + } + + if (!extensions.ContainsKey(property.Name)) + { + extensions[property.Name] = property.Value.GetRawText(); + } + } + + parameters.Extensions = new ReadOnlyDictionary(extensions); + } + + private static IReadOnlyDictionary MergeHeaders( + IReadOnlyDictionary modelHeaders, + IReadOnlyDictionary configurationHeaders) + { + var result = new Dictionary(modelHeaders, StringComparer.OrdinalIgnoreCase); + foreach (var pair in configurationHeaders) + { + result[pair.Key] = pair.Value; + } + + return new ReadOnlyDictionary(result); + } + + private static void CopyNullableHeaders( + IReadOnlyDictionary source, + IDictionary destination) + { + foreach (var pair in source) + { + destination[pair.Key] = pair.Value; + } + } + + private static void ValidateConfiguration(ResolvedGameModelTransportConfiguration configuration) + { + if (configuration.BaseUrl is { } baseUrl + && (!baseUrl.IsAbsoluteUri + || baseUrl.UserInfo.Length > 0 + || baseUrl.Fragment.Length > 0 + || baseUrl.Scheme != Uri.UriSchemeHttp && baseUrl.Scheme != Uri.UriSchemeHttps)) + { + throw new InvalidOperationException( + "A model provider base URL must be an absolute HTTP or HTTPS URL without embedded credentials."); + } + + if (configuration.Headers.Count > 64 || configuration.Options.Count > 256) + { + throw new InvalidOperationException("A model provider configuration contains too many entries."); + } + + ValidateHeaderValues(configuration.Headers); + + foreach (var pair in configuration.Options) + { + if (string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.Length > 256 + || pair.Key.Any(character => char.IsControl(character) || char.IsWhiteSpace(character)) + || pair.Value is null + || pair.Value.Length > 1_000_000 + || pair.Value.IndexOf('\0') >= 0) + { + throw new InvalidOperationException("A model provider option is invalid."); + } + } + } + + private static void ValidateHeaders(string api, IReadOnlyDictionary headers) + { + ValidateHeaderValues(headers); + foreach (var pair in headers) + { + if (ModelApiHasProtectedHeader(api, pair.Key)) + { + throw new InvalidOperationException( + $"Header '{pair.Key}' is controlled by the provider transport and cannot be configured or deleted."); + } + } + } + + private static void ValidateHeaderValues(IReadOnlyDictionary headers) + { + try + { + ProviderHeaderGuard.ValidateMerge(headers, nameof(headers)); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException("A model provider header is invalid.", exception); + } + } + + private static bool ModelApiHasProtectedHeader(string api, string name) => + string.Equals(api, BuiltInGameModelApis.BedrockConverseStream, StringComparison.Ordinal) + && (string.Equals(name, "Authorization", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("x-amz-", StringComparison.OrdinalIgnoreCase)); + + private static Uri Endpoint(Uri? configured, string defaultBaseUrl, string suffix) + { + var baseUri = configured ?? new Uri(defaultBaseUrl, UriKind.Absolute); + var path = baseUri.AbsolutePath.TrimEnd('/'); + var expected = "/" + suffix.TrimStart('/'); + if (!path.EndsWith(expected, StringComparison.OrdinalIgnoreCase)) + { + var builder = new UriBuilder(baseUri) + { + Path = path + expected, + }; + baseUri = builder.Uri; + } + + return baseUri; + } + + private bool AllowsInsecureEndpoint(Uri endpoint) => + _options.AllowInsecureHttp || endpoint.IsLoopback; + + private static Uri GoogleEndpoint(Uri? configured) + { + if (configured is null) + { + return new Uri( + "https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent", + UriKind.Absolute); + } + + if (configured.OriginalString.Contains("{model}", StringComparison.Ordinal)) + { + return configured; + } + + return GoogleTemplate(configured); + } + + private Uri VertexEndpoint( + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration) + { + var configured = ResolveBaseUrl(model, configuration); + if (configured is not null) + { + return configured.OriginalString.Contains("{model}", StringComparison.Ordinal) + ? configured + : GoogleTemplate(configured); + } + + var project = Option(configuration, BuiltInGameModelConfigurationKeys.GoogleProject) + ?? Option(configuration, "GOOGLE_VERTEX_PROJECT") + ?? Option(configuration, "GOOGLE_CLOUD_PROJECT") + ?? Option(configuration, "GCLOUD_PROJECT") + ?? throw new InvalidOperationException("A Google Cloud project is required for Vertex AI."); + var location = Option(configuration, BuiltInGameModelConfigurationKeys.GoogleLocation) + ?? Option(configuration, "GOOGLE_VERTEX_LOCATION") + ?? Option(configuration, "GOOGLE_CLOUD_LOCATION") + ?? throw new InvalidOperationException("A Google Cloud location is required for Vertex AI."); + return GoogleVertexCredentials.Endpoint(project, location); + } + + private Uri? ResolveBaseUrl( + GameModelDescriptor model, + ResolvedGameModelTransportConfiguration configuration) + { + var value = configuration.BaseUrl ?? model.BaseUrl; + if (value is null || !value.OriginalString.Contains("${", StringComparison.Ordinal)) + { + return value; + } + + var expanded = value.OriginalString; + var substitutions = 0; + while (true) + { + var start = expanded.IndexOf("${", StringComparison.Ordinal); + if (start < 0) + { + break; + } + + var end = expanded.IndexOf('}', start + 2); + if (end < 0 || ++substitutions > 32) + { + throw new InvalidOperationException("A model provider base URL contains an invalid environment placeholder."); + } + + var name = expanded.Substring(start + 2, end - start - 2); + if (!IsEnvironmentVariableName(name)) + { + throw new InvalidOperationException("A model provider base URL contains an invalid environment placeholder."); + } + + var replacement = Option(configuration, name) ?? EnvironmentValue(name); + if (replacement is null) + { + throw new InvalidOperationException( + $"Model provider '{model.ProviderId}' requires environment setting '{name}'."); + } + + expanded = expanded.Substring(0, start) + + Uri.EscapeDataString(replacement) + + expanded.Substring(end + 1); + } + + return new Uri(expanded, UriKind.Absolute); + } + + private static Uri GoogleTemplate(Uri baseUri) => + new( + baseUri.GetLeftPart(UriPartial.Path).TrimEnd('/') + + "/models/{model}:streamGenerateContent" + + baseUri.Query, + UriKind.Absolute); + + private static OpenAIAuthenticationStyle ParseOpenAiAuthenticationStyle( + ResolvedGameModelTransportConfiguration configuration, + GameCredential? credential, + IReadOnlyDictionary headers) + { + var value = Option(configuration, BuiltInGameModelConfigurationKeys.AuthenticationStyle); + if (value is null) + { + return credential is null + || HasHeaderValue(headers, "Authorization") + || HasHeaderValue(headers, Option( + configuration, + BuiltInGameModelConfigurationKeys.AuthenticationHeader) ?? "api-key") + ? OpenAIAuthenticationStyle.None + : OpenAIAuthenticationStyle.Bearer; + } + + return value.ToLowerInvariant() switch + { + "bearer" => OpenAIAuthenticationStyle.Bearer, + "api-key" or "api-key-header" => OpenAIAuthenticationStyle.ApiKeyHeader, + "none" => OpenAIAuthenticationStyle.None, + _ => throw new InvalidOperationException($"Unknown OpenAI authentication style '{value}'."), + }; + } + + private static OpenAICompatibleMaxTokensField ParseMaxTokensField(string? value) => + value?.ToLowerInvariant() switch + { + null or "max_completion_tokens" => OpenAICompatibleMaxTokensField.MaxCompletionTokens, + "max_tokens" => OpenAICompatibleMaxTokensField.MaxTokens, + _ => throw new InvalidOperationException($"Unknown maximum-token field '{value}'."), + }; + + private static OpenAICompatibleThinkingFormat ParseThinkingFormat(string? value) => + value?.ToLowerInvariant() switch + { + null or "openai" => OpenAICompatibleThinkingFormat.OpenAI, + "openrouter" => OpenAICompatibleThinkingFormat.OpenRouter, + "deepseek" => OpenAICompatibleThinkingFormat.DeepSeek, + "together" => OpenAICompatibleThinkingFormat.Together, + "baseten" => OpenAICompatibleThinkingFormat.Baseten, + "zai" => OpenAICompatibleThinkingFormat.Zai, + "qwen" => OpenAICompatibleThinkingFormat.Qwen, + "chat-template" => OpenAICompatibleThinkingFormat.ChatTemplate, + "qwen-chat-template" => OpenAICompatibleThinkingFormat.QwenChatTemplate, + "string-thinking" => OpenAICompatibleThinkingFormat.StringThinking, + "ant-ling" => OpenAICompatibleThinkingFormat.AntLing, + _ => throw new InvalidOperationException($"Unknown thinking format '{value}'."), + }; + + private static OpenAICompatibleCacheControlFormat ParseCacheControlFormat(string? value) => + value?.ToLowerInvariant() switch + { + null or "none" => OpenAICompatibleCacheControlFormat.None, + "anthropic" => OpenAICompatibleCacheControlFormat.Anthropic, + _ => throw new InvalidOperationException($"Unknown cache-control format '{value}'."), + }; + + private static OpenAICompatibleSessionAffinityFormat ParseCompatibleSessionAffinityFormat(string? value) => + value?.ToLowerInvariant() switch + { + null or "openai" => OpenAICompatibleSessionAffinityFormat.OpenAI, + "openai-nosession" => OpenAICompatibleSessionAffinityFormat.OpenAIWithoutSessionHeader, + "openrouter" => OpenAICompatibleSessionAffinityFormat.OpenRouter, + _ => throw new InvalidOperationException($"Unknown session-affinity format '{value}'."), + }; + + private static OpenAISessionAffinityFormat ParseOpenAiSessionAffinityFormat(string? value) => + value?.ToLowerInvariant() switch + { + null or "openai" => OpenAISessionAffinityFormat.OpenAI, + "openai-nosession" => OpenAISessionAffinityFormat.OpenAIWithoutSessionHeader, + "openrouter" => OpenAISessionAffinityFormat.OpenRouter, + "codex" => OpenAISessionAffinityFormat.Codex, + _ => throw new InvalidOperationException($"Unknown session-affinity format '{value}'."), + }; + + private static OpenAICompatibleDeferredToolsMode ParseDeferredToolsMode(string? value) => + value?.ToLowerInvariant() switch + { + null or "none" => OpenAICompatibleDeferredToolsMode.None, + "kimi" => OpenAICompatibleDeferredToolsMode.Kimi, + _ => throw new InvalidOperationException($"Unknown deferred-tools mode '{value}'."), + }; + + private static MistralReasoningMode ParseMistralReasoningMode(string? value) => + value?.ToLowerInvariant() switch + { + null or "auto" => MistralReasoningMode.Auto, + "prompt" or "prompt-mode" => MistralReasoningMode.PromptMode, + "effort" => MistralReasoningMode.Effort, + _ => throw new InvalidOperationException($"Unknown Mistral reasoning mode '{value}'."), + }; + + private static string? Option(ResolvedGameModelTransportConfiguration configuration, string key) => + configuration.Options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + + private static string RequireBoundedConfigurationValue( + string value, + int maximumLength, + string label) + { + var normalized = value?.Trim(); + if (string.IsNullOrEmpty(normalized) + || normalized.Length > maximumLength + || normalized.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + throw new InvalidOperationException( + $"{label} must be a non-empty value of at most {maximumLength} characters without whitespace or controls."); + } + + return normalized; + } + + private static bool BooleanOption( + ResolvedGameModelTransportConfiguration configuration, + string key, + bool defaultValue) + { + var value = Option(configuration, key); + if (value is null) + { + return defaultValue; + } + + return bool.TryParse(value, out var result) + ? result + : throw new InvalidOperationException($"Model provider option '{key}' must be true or false."); + } + + private static string? BearerValue(IReadOnlyDictionary headers) + { + if (!headers.TryGetValue("Authorization", out var value) + || value is null + || !value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var credential = value.Substring("Bearer ".Length).Trim(); + return credential.Length == 0 ? null : credential; + } + + private static bool HasHeaderValue(IReadOnlyDictionary headers, string name) => + headers.TryGetValue(name, out var value) && value is not null; + + private static string EnvironmentPrefix(string providerId) + { + var result = new StringBuilder(providerId.Length); + foreach (var character in providerId) + { + result.Append(char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : '_'); + } + + return result.ToString(); + } + + private static ModelStreamEvent Failure(string provider, string? api, string model, string message) + { + var response = new ModelResponse( + Array.Empty(), + ModelStopReason.Error, + errorMessage: message, + provider: provider, + api: api, + responseModel: model, + diagnostics: new[] + { + new ModelDiagnostic("model_runtime_error", message, ModelDiagnosticSeverity.Error), + }); + return ModelStreamEvent.Terminal(response); + } + + private static string ErrorMessage(Exception exception) + { + var message = string.IsNullOrWhiteSpace(exception.Message) + ? exception.GetType().Name + : exception.Message; + return message.Length <= 4096 ? message : message.Substring(0, 4096); + } + + private static string RequireId(string value, string parameterName) => + string.IsNullOrWhiteSpace(value) || value.Length > 512 + ? throw new ArgumentException("A non-empty identifier of at most 512 characters is required.", parameterName) + : value; + + private sealed class DirectoryDispatchProvider : IModelProvider + { + private readonly BuiltInGameModelRuntime _runtime; + private readonly string _providerId; + + public DirectoryDispatchProvider(BuiltInGameModelRuntime runtime, string providerId) + { + _runtime = runtime; + _providerId = providerId; + } + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) => + _runtime.StreamWithBoundaryAsync( + _providerId, + request ?? throw new ArgumentNullException(nameof(request)), + cancellationToken); + } + + private sealed class EnvironmentChainAuthentication : IGameProviderAuthentication + { + private readonly IReadOnlyList> _variables; + private readonly Func _read; + private readonly Func? _isAmbientConfigured; + + public EnvironmentChainAuthentication( + IReadOnlyList> variables, + Func read, + Func? isAmbientConfigured = null) + { + _variables = Array.AsReadOnly((variables ?? throw new ArgumentNullException(nameof(variables))).ToArray()); + if (_variables.Any(variable => string.IsNullOrWhiteSpace(variable.Key))) + { + throw new ArgumentException("Environment variable names must be non-empty.", nameof(variables)); + } + + if (_variables.Any(variable => !Enum.IsDefined(typeof(GameCredentialKind), variable.Value))) + { + throw new ArgumentOutOfRangeException(nameof(variables)); + } + + _read = read ?? throw new ArgumentNullException(nameof(read)); + _isAmbientConfigured = isAmbientConfigured; + } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var credential = ReadCredential(); + var configured = credential is not null || _isAmbientConfigured?.Invoke() == true; + return new ValueTask(new GameProviderAuthStatus( + configured, + credential is null && configured ? "application-default" : "environment", + credential?.Kind, + error: configured ? null : "No supported environment credential is configured.")); + } + catch (ArgumentException) + { + return new ValueTask(new GameProviderAuthStatus( + false, + "environment", + error: "A supported environment variable contains an invalid credential.")); + } + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var credential = ReadCredential(); + return new ValueTask(credential is null + ? null + : new GameProviderAuthResolution(credential, "environment")); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Environment authentication does not expose a login flow."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Environment authentication cannot modify the process environment."); + + private GameCredential? ReadCredential() + { + foreach (var variable in _variables) + { + var secret = _read(variable.Key); + if (!string.IsNullOrWhiteSpace(secret)) + { + return new GameCredential(variable.Value, secret); + } + } + + return null; + } + } + + private sealed class ModelCompatibility + { + private ModelCompatibility() + { + } + + public bool? SupportsTemperature { get; private set; } + + public bool? Interleaved { get; private set; } + + public string? InterleavedField { get; private set; } + + public bool? SupportsStore { get; private set; } + + public bool? SupportsDeveloperRole { get; private set; } + + public bool? SupportsReasoningEffort { get; private set; } + + public bool? SupportsUsageInStreaming { get; private set; } + + public bool? SupportsFinishReason { get; private set; } + + public string? MaxTokensField { get; private set; } + + public bool? RequiresToolResultName { get; private set; } + + public bool? RequiresAssistantAfterToolResult { get; private set; } + + public bool? RequiresThinkingAsText { get; private set; } + + public bool? RequiresReasoningContentOnAssistantMessages { get; private set; } + + public string? ThinkingFormat { get; private set; } + + public bool? ZaiToolStream { get; private set; } + + public bool? SupportsThinkingTokenBudget { get; private set; } + + public bool? SupportsStrictMode { get; private set; } + + public bool? SupportsOpenAiGrammarTools { get; private set; } + + public string? CacheControlFormat { get; private set; } + + public bool? SendSessionAffinityHeaders { get; private set; } + + public string? SessionAffinityFormat { get; private set; } + + public string? DeferredToolsMode { get; private set; } + + public bool? SupportsLongCacheRetention { get; private set; } + + public string? ChatTemplateArgumentsJson { get; private set; } + + public string? ChatTemplateKeywordArgumentsJson { get; private set; } + + public bool? SupportsEagerToolInputStreaming { get; private set; } + + public bool? SupportsCacheControlOnTools { get; private set; } + + public bool? ForceAdaptiveThinking { get; private set; } + + public bool? AllowEmptySignature { get; private set; } + + public bool? SupportsStrictTools { get; private set; } + + public bool? SupportsToolReferences { get; private set; } + + public bool? SupportsAdditionalTools { get; private set; } + + public bool? SupportsToolSearch { get; private set; } + + public bool? SupportsExplicitPromptCacheMode { get; private set; } + + public bool? UseLegacyOpenApiToolSchemas { get; private set; } + + public string? ReasoningMode { get; private set; } + + public static ModelCompatibility Parse(string? json) + { + if (json is null) + { + return new ModelCompatibility(); + } + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var compatibility = new ModelCompatibility + { + SupportsTemperature = OptionalBoolean(root, "supportsTemperature"), + SupportsStore = OptionalBoolean(root, "supportsStore"), + SupportsDeveloperRole = OptionalBoolean(root, "supportsDeveloperRole"), + SupportsReasoningEffort = OptionalBoolean(root, "supportsReasoningEffort"), + SupportsUsageInStreaming = OptionalBoolean(root, "supportsUsageInStreaming"), + SupportsFinishReason = OptionalBoolean(root, "supportsFinishReason"), + MaxTokensField = OptionalString(root, "maxTokensField"), + RequiresToolResultName = OptionalBoolean(root, "requiresToolResultName"), + RequiresAssistantAfterToolResult = OptionalBoolean(root, "requiresAssistantAfterToolResult"), + RequiresThinkingAsText = OptionalBoolean(root, "requiresThinkingAsText"), + RequiresReasoningContentOnAssistantMessages = + OptionalBoolean(root, "requiresReasoningContentOnAssistantMessages"), + ThinkingFormat = OptionalString(root, "thinkingFormat"), + ZaiToolStream = OptionalBoolean(root, "zaiToolStream"), + SupportsThinkingTokenBudget = OptionalBoolean(root, "supportsThinkingTokenBudget"), + SupportsStrictMode = OptionalBoolean(root, "supportsStrictMode"), + SupportsOpenAiGrammarTools = OptionalBoolean(root, "supportsOpenAIGrammarTools"), + CacheControlFormat = OptionalString(root, "cacheControlFormat"), + SendSessionAffinityHeaders = OptionalBoolean(root, "sendSessionAffinityHeaders"), + SessionAffinityFormat = OptionalString(root, "sessionAffinityFormat"), + DeferredToolsMode = OptionalString(root, "deferredToolsMode"), + SupportsLongCacheRetention = OptionalBoolean(root, "supportsLongCacheRetention"), + ChatTemplateArgumentsJson = OptionalObjectJson(root, "chatTemplateArgs"), + ChatTemplateKeywordArgumentsJson = OptionalObjectJson(root, "chatTemplateKwargs"), + SupportsEagerToolInputStreaming = OptionalBoolean(root, "supportsEagerToolInputStreaming"), + SupportsCacheControlOnTools = OptionalBoolean(root, "supportsCacheControlOnTools"), + ForceAdaptiveThinking = OptionalBoolean(root, "forceAdaptiveThinking"), + AllowEmptySignature = OptionalBoolean(root, "allowEmptySignature"), + SupportsStrictTools = OptionalBoolean(root, "supportsStrictTools"), + SupportsToolReferences = OptionalBoolean(root, "supportsToolReferences"), + SupportsAdditionalTools = OptionalBoolean(root, "supportsAdditionalTools"), + SupportsToolSearch = OptionalBoolean(root, "supportsToolSearch"), + SupportsExplicitPromptCacheMode = OptionalBoolean(root, "supportsExplicitPromptCacheMode"), + UseLegacyOpenApiToolSchemas = OptionalBoolean(root, "useLegacyOpenApiToolSchemas"), + ReasoningMode = OptionalString(root, "reasoningMode"), + }; + if (root.TryGetProperty("interleaved", out var value)) + { + if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + compatibility.Interleaved = value.GetBoolean(); + } + else if (value.ValueKind == JsonValueKind.Object) + { + if (!value.TryGetProperty("field", out var fieldElement) + || fieldElement.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(fieldElement.GetString())) + { + throw new InvalidOperationException("A model interleaved-reasoning field is invalid."); + } + + compatibility.InterleavedField = fieldElement.GetString(); + compatibility.Interleaved = true; + } + else + { + throw new InvalidOperationException("A model interleaved-reasoning setting is invalid."); + } + } + + return compatibility; + } + + private static bool? OptionalBoolean(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => throw new InvalidOperationException($"Model compatibility field '{name}' must be true or false."), + }; + } + + private static string? OptionalString(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value)) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(value.GetString()) + || value.GetString()!.Length > 128) + { + throw new InvalidOperationException($"Model compatibility field '{name}' must be a short string."); + } + + return value.GetString(); + } + + private static string? OptionalObjectJson(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value)) + { + return null; + } + + if (value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException($"Model compatibility field '{name}' must be an object."); + } + + return value.GetRawText(); + } + } +} diff --git a/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntimeOptions.cs b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntimeOptions.cs new file mode 100644 index 0000000..58cfc58 --- /dev/null +++ b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntimeOptions.cs @@ -0,0 +1,213 @@ +using System.Collections.ObjectModel; +using OpenGameAgent.Kernel; +using OpenGameAgent.Providers.Bedrock; +using OpenGameAgent.Providers.Google; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Models.BuiltIn; + +public static class BuiltInGameModelApis +{ + public const string AnthropicMessages = "anthropic-messages"; + public const string AzureOpenAiResponses = "azure-openai-responses"; + public const string BedrockConverseStream = "bedrock-converse-stream"; + public const string GoogleGenerativeAi = "google-generative-ai"; + public const string GoogleVertex = "google-vertex"; + public const string MistralConversations = "mistral-conversations"; + public const string OpenAiCodexResponses = "openai-codex-responses"; + public const string OpenAiCompletions = "openai-completions"; + public const string OpenAiResponses = "openai-responses"; +} + +public static class BuiltInGameModelConfigurationKeys +{ + public const string EnvironmentVariablesMetadata = "environmentVariables"; + public const string AuthenticationHeader = "auth.header"; + public const string AuthenticationScheme = "auth.scheme"; + public const string AuthenticationStyle = "auth.style"; + public const string AzureApiVersion = "azure.api-version"; + public const string AzureDeploymentName = "azure.deployment-name"; + public const string AzureResourceName = "azure.resource-name"; + public const string GoogleProject = "google.project"; + public const string GoogleLocation = "google.location"; + public const string OpenAiCodexAccountId = "openai-codex.account-id"; + public const string OpenAiCodexEnvironmentVariable = "openai-codex.environment-variable"; + public const string AwsRegion = "aws.region"; + public const string AwsProfile = "aws.profile"; + public const string AwsSkipAuthentication = "aws.skip-authentication"; + public const string AwsSecretAccessKeyMetadata = "aws.secret-access-key"; + public const string AwsSessionTokenMetadata = "aws.session-token"; +} + +public sealed class GameModelProviderTransportConfiguration +{ + public Uri? BaseUrl { get; set; } + + public BedrockConverseTransport? BedrockTransport { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IDictionary Options { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); +} + +public sealed class GameModelTransportConfigurationContext +{ + internal GameModelTransportConfigurationContext( + GameProviderDescriptor provider, + GameModelDescriptor model, + ModelRequest request) + { + Provider = provider; + Model = model; + Request = request; + } + + public GameProviderDescriptor Provider { get; } + + public GameModelDescriptor Model { get; } + + public ModelRequest Request { get; } +} + +public delegate ValueTask GameModelTransportConfigurationResolver( + GameModelTransportConfigurationContext context, + CancellationToken cancellationToken); + +public delegate IGameProviderAuthentication BuiltInGameProviderAuthenticationFactory( + GameProviderDescriptor provider, + IReadOnlyList models); + +public sealed class BuiltInGameModelRuntimeOptions +{ + public BuiltInGameModelRuntimeOptions(HttpClient httpClient) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public HttpClient HttpClient { get; } + + public bool AllowInsecureHttp { get; set; } + + public GameModelDirectorySnapshot Directory { get; set; } = GameModelDirectory.LoadBundled(); + + public GameModelCatalog Catalog { get; set; } = new(); + + public bool ReplaceExistingProviders { get; set; } + + public IDictionary Authentications { get; } = + new Dictionary(StringComparer.Ordinal); + + public BuiltInGameProviderAuthenticationFactory? CreateAuthentication { get; set; } + + /// + /// Trusted host configuration. Base URLs and outbound headers can redirect requests or change their authority. + /// + public IDictionary ProviderConfigurations { get; } = + new Dictionary(StringComparer.Ordinal); + + /// + /// Resolves trusted per-request transport configuration. The callback may redirect requests or change headers. + /// + public GameModelTransportConfigurationResolver? ResolveConfigurationAsync { get; set; } + + public Func GetEnvironmentVariable { get; set; } = Environment.GetEnvironmentVariable; + + public GoogleCredentialProvider? VertexApplicationDefaultCredential { get; set; } = + GoogleVertexCredentials.ApplicationDefault(); + + /// + /// Observes bounded, allowlisted response metadata. Request headers and credentials are never included. + /// + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; +} + +internal sealed class ResolvedGameModelTransportConfiguration +{ + private ResolvedGameModelTransportConfiguration( + Uri? baseUrl, + BedrockConverseTransport? bedrockTransport, + IReadOnlyDictionary headers, + IReadOnlyDictionary options) + { + BaseUrl = baseUrl; + BedrockTransport = bedrockTransport; + Headers = headers; + Options = options; + } + + public Uri? BaseUrl { get; } + + public BedrockConverseTransport? BedrockTransport { get; } + + public IReadOnlyDictionary Headers { get; } + + public IReadOnlyDictionary Options { get; } + + public static ResolvedGameModelTransportConfiguration Empty { get; } = new( + null, + null, + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase)), + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase))); + + public static ResolvedGameModelTransportConfiguration Snapshot(GameModelProviderTransportConfiguration? value) + { + if (value is null) + { + return Empty; + } + + return new ResolvedGameModelTransportConfiguration( + value.BaseUrl, + value.BedrockTransport, + CopyHeaders(value.Headers), + CopyOptions(value.Options)); + } + + public ResolvedGameModelTransportConfiguration Overlay(ResolvedGameModelTransportConfiguration value) + { + var headers = new Dictionary(Headers, StringComparer.OrdinalIgnoreCase); + foreach (var pair in value.Headers) + { + headers[pair.Key] = pair.Value; + } + + var options = new Dictionary(Options, StringComparer.OrdinalIgnoreCase); + foreach (var pair in value.Options) + { + options[pair.Key] = pair.Value; + } + + return new ResolvedGameModelTransportConfiguration( + value.BaseUrl ?? BaseUrl, + value.BedrockTransport ?? BedrockTransport, + new ReadOnlyDictionary(headers), + new ReadOnlyDictionary(options)); + } + + private static IReadOnlyDictionary CopyHeaders(IDictionary source) + { + if (source is null) + { + throw new ArgumentException("A model transport configuration dictionary cannot be null."); + } + + return new ReadOnlyDictionary( + new Dictionary(source, StringComparer.OrdinalIgnoreCase)); + } + + private static IReadOnlyDictionary CopyOptions(IDictionary source) + { + if (source is null) + { + throw new ArgumentException("A model transport configuration dictionary cannot be null."); + } + + return new ReadOnlyDictionary( + new Dictionary(source, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/src/OpenGameAgent.Models.BuiltIn/OpenGameAgent.Models.BuiltIn.csproj b/src/OpenGameAgent.Models.BuiltIn/OpenGameAgent.Models.BuiltIn.csproj new file mode 100644 index 0000000..d60b9bf --- /dev/null +++ b/src/OpenGameAgent.Models.BuiltIn/OpenGameAgent.Models.BuiltIn.csproj @@ -0,0 +1,16 @@ + + + netstandard2.1 + Ready-to-use model directory runtime backed by the official OpenGameAgent providers. + + + + + + + + + + + + diff --git a/src/OpenGameAgent.Models.BuiltIn/packages.lock.json b/src/OpenGameAgent.Models.BuiltIn/packages.lock.json new file mode 100644 index 0000000..c44a3f1 --- /dev/null +++ b/src/OpenGameAgent.Models.BuiltIn/packages.lock.json @@ -0,0 +1,196 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.6" + } + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openaicompatible": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Models/CancellableOperation.cs b/src/OpenGameAgent.Models/CancellableOperation.cs new file mode 100644 index 0000000..dc91ee8 --- /dev/null +++ b/src/OpenGameAgent.Models/CancellableOperation.cs @@ -0,0 +1,58 @@ +namespace OpenGameAgent.Models; + +internal static class CancellableOperation +{ + public static async ValueTask WaitAsync( + ValueTask operation, + CancellationToken cancellationToken) + { + var task = operation.AsTask(); + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + return await task.ConfigureAwait(false); + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(true), + canceled); + if (task != await Task.WhenAny(task, canceled.Task).ConfigureAwait(false)) + { + ObserveLateFailure(task); + throw new OperationCanceledException(cancellationToken); + } + + return await task.ConfigureAwait(false); + } + + public static async ValueTask WaitAsync( + ValueTask operation, + CancellationToken cancellationToken) + { + var task = operation.AsTask(); + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + await task.ConfigureAwait(false); + return; + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(true), + canceled); + if (task != await Task.WhenAny(task, canceled.Task).ConfigureAwait(false)) + { + ObserveLateFailure(task); + throw new OperationCanceledException(cancellationToken); + } + + await task.ConfigureAwait(false); + } + + private static void ObserveLateFailure(Task task) => + _ = task.ContinueWith( + static failed => _ = failed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); +} diff --git a/src/OpenGameAgent.Models/Credentials.cs b/src/OpenGameAgent.Models/Credentials.cs index 25b254a..2f207e2 100644 --- a/src/OpenGameAgent.Models/Credentials.cs +++ b/src/OpenGameAgent.Models/Credentials.cs @@ -137,7 +137,8 @@ public interface IGameCredentialStore public sealed class InMemoryGameCredentialStore : IGameCredentialStore { private readonly Dictionary _credentials = new(); - private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _keyGates = new(); + private readonly object _stateGate = new(); private readonly int _capacity; public InMemoryGameCredentialStore(int capacity = 128) @@ -153,15 +154,11 @@ public InMemoryGameCredentialStore(int capacity = 128) public async ValueTask GetAsync(GameCredentialKey key, CancellationToken cancellationToken) { key.EnsureValid(nameof(key)); - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using var lease = await AcquireAsync(key, cancellationToken).ConfigureAwait(false); + lock (_stateGate) { return _credentials.TryGetValue(key, out var value) ? value : null; } - finally - { - _gate.Release(); - } } public async ValueTask SetAsync( @@ -175,9 +172,10 @@ public async ValueTask SetAsync( throw new ArgumentNullException(nameof(credential)); } - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using var lease = await AcquireAsync(key, cancellationToken).ConfigureAwait(false); + lock (_stateGate) { + cancellationToken.ThrowIfCancellationRequested(); if (!_credentials.ContainsKey(key) && _credentials.Count >= _capacity) { throw new InvalidOperationException("The credential store reached its capacity."); @@ -185,24 +183,17 @@ public async ValueTask SetAsync( _credentials[key] = credential; } - finally - { - _gate.Release(); - } } public async ValueTask RemoveAsync(GameCredentialKey key, CancellationToken cancellationToken) { key.EnsureValid(nameof(key)); - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using var lease = await AcquireAsync(key, cancellationToken).ConfigureAwait(false); + lock (_stateGate) { + cancellationToken.ThrowIfCancellationRequested(); return _credentials.Remove(key); } - finally - { - _gate.Release(); - } } public async ValueTask ModifyAsync( @@ -216,12 +207,27 @@ public async ValueTask RemoveAsync(GameCredentialKey key, CancellationToke throw new ArgumentNullException(nameof(mutation)); } - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try + return await CancellableOperation.WaitAsync( + ModifyCoreAsync(key, mutation, cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ModifyCoreAsync( + GameCredentialKey key, + Func> mutation, + CancellationToken cancellationToken) + { + using var lease = await AcquireAsync(key, cancellationToken).ConfigureAwait(false); + GameCredential? current; + lock (_stateGate) + { + _credentials.TryGetValue(key, out current); + } + + var next = await mutation(current, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + lock (_stateGate) { - _credentials.TryGetValue(key, out var current); - var next = await mutation(current, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); if (next is null) { _credentials.Remove(key); @@ -238,9 +244,81 @@ public async ValueTask RemoveAsync(GameCredentialKey key, CancellationToke return next; } - finally + } + + private async ValueTask AcquireAsync( + GameCredentialKey key, + CancellationToken cancellationToken) + { + CredentialGate gate; + lock (_stateGate) + { + if (!_keyGates.TryGetValue(key, out gate!)) + { + gate = new CredentialGate(); + _keyGates.Add(key, gate); + } + + gate.References++; + } + + try + { + await gate.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new CredentialLease(this, key, gate); + } + catch + { + ReleaseReference(key, gate, releaseSemaphore: false); + throw; + } + } + + private void ReleaseReference(GameCredentialKey key, CredentialGate gate, bool releaseSemaphore) + { + if (releaseSemaphore) + { + gate.Semaphore.Release(); + } + + lock (_stateGate) + { + gate.References--; + if (gate.References == 0) + { + _keyGates.Remove(key); + gate.Semaphore.Dispose(); + } + } + } + + private sealed class CredentialGate + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + + public int References { get; set; } + } + + private sealed class CredentialLease : IDisposable + { + private InMemoryGameCredentialStore? _owner; + private readonly GameCredentialKey _key; + private readonly CredentialGate _gate; + + public CredentialLease( + InMemoryGameCredentialStore owner, + GameCredentialKey key, + CredentialGate gate) + { + _owner = owner; + _key = key; + _gate = gate; + } + + public void Dispose() { - _gate.Release(); + var owner = Interlocked.Exchange(ref _owner, null); + owner?.ReleaseReference(_key, _gate, releaseSemaphore: true); } } } @@ -289,15 +367,111 @@ public GameProviderAuthStatus( public sealed class GameProviderAuthResolution { - public GameProviderAuthResolution(GameCredential credential, string source) + public GameProviderAuthResolution( + GameCredential? credential, + string source, + Uri? baseUrl = null, + IReadOnlyDictionary? headers = null, + IReadOnlyDictionary? configuration = null) { - Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + if (baseUrl is not null + && (!baseUrl.IsAbsoluteUri + || baseUrl.UserInfo.Length > 0 + || baseUrl.Fragment.Length > 0 + || baseUrl.Scheme != Uri.UriSchemeHttp && baseUrl.Scheme != Uri.UriSchemeHttps)) + { + throw new ArgumentException( + "An authentication base URL must be an absolute HTTP or HTTPS URI without embedded credentials or a fragment.", + nameof(baseUrl)); + } + Source = GameModelDescriptor.RequireId(source, nameof(source)); + Credential = credential; + BaseUrl = baseUrl; + Headers = CopyHeaders(headers); + Configuration = CopyConfiguration(configuration); } - public GameCredential Credential { get; } + public GameCredential? Credential { get; } public string Source { get; } + + public Uri? BaseUrl { get; } + + public IReadOnlyDictionary Headers { get; } + + public IReadOnlyDictionary Configuration { get; } + + private static IReadOnlyDictionary CopyHeaders( + IReadOnlyDictionary? source) + { + if (source is { Count: > 64 }) + { + throw new ArgumentException("Authentication headers cannot contain more than 64 entries.", nameof(source)); + } + + var copy = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in source ?? new Dictionary()) + { + if (!IsHeaderName(pair.Key) + || pair.Value is { Length: > 16_384 } + || pair.Value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || !copy.TryAdd(pair.Key, pair.Value)) + { + throw new ArgumentException( + "Authentication headers contain an invalid or case-insensitively duplicate entry.", + nameof(source)); + } + } + + return new ReadOnlyDictionary(copy); + } + + private static IReadOnlyDictionary CopyConfiguration( + IReadOnlyDictionary? source) + { + if (source is { Count: > 256 }) + { + throw new ArgumentException( + "Authentication configuration cannot contain more than 256 entries.", + nameof(source)); + } + + var copy = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in source ?? new Dictionary()) + { + var key = GameModelDescriptor.RequireId(pair.Key, nameof(source)); + if (pair.Value is null + || pair.Value.Length > 16_384 + || pair.Value.IndexOf('\0') >= 0 + || !copy.TryAdd(key, pair.Value)) + { + throw new ArgumentException( + "Authentication configuration contains an invalid or case-insensitively duplicate entry.", + nameof(source)); + } + } + + return new ReadOnlyDictionary(copy); + } + + private static bool IsHeaderName(string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 256) + { + return false; + } + + try + { + using var request = new System.Net.Http.HttpRequestMessage(); + return request.Headers.TryAddWithoutValidation(name, "value"); + } + catch (FormatException) + { + return false; + } + } } public sealed class GameAuthInteraction @@ -468,6 +642,7 @@ public sealed class StoredGameProviderAuthentication : IGameProviderAuthenticati private readonly Func>? _refresh; private readonly Func _clock; private readonly TimeSpan _refreshSkew; + private readonly int _refreshTimeoutMilliseconds; private readonly int _credentialCommitTimeoutMilliseconds; public StoredGameProviderAuthentication( @@ -479,6 +654,7 @@ public StoredGameProviderAuthentication( string profile = "default", Func? clock = null, TimeSpan? refreshSkew = null, + int refreshTimeoutMilliseconds = 15_000, int credentialCommitTimeoutMilliseconds = 10_000) { _key = new GameCredentialKey(providerId, profile); @@ -501,17 +677,23 @@ public StoredGameProviderAuthentication( _login = login; _refresh = refresh; _clock = clock ?? (() => DateTimeOffset.UtcNow); - _refreshSkew = refreshSkew ?? TimeSpan.FromMinutes(1); + _refreshSkew = refreshSkew ?? TimeSpan.FromMinutes(5); if (_refreshSkew < TimeSpan.Zero || _refreshSkew > TimeSpan.FromHours(24)) { throw new ArgumentOutOfRangeException(nameof(refreshSkew)); } + if (refreshTimeoutMilliseconds < 100 || refreshTimeoutMilliseconds > 300_000) + { + throw new ArgumentOutOfRangeException(nameof(refreshTimeoutMilliseconds)); + } + if (credentialCommitTimeoutMilliseconds < 100 || credentialCommitTimeoutMilliseconds > 300_000) { throw new ArgumentOutOfRangeException(nameof(credentialCommitTimeoutMilliseconds)); } + _refreshTimeoutMilliseconds = refreshTimeoutMilliseconds; _credentialCommitTimeoutMilliseconds = credentialCommitTimeoutMilliseconds; } @@ -562,8 +744,24 @@ public async ValueTask CheckAsync(CancellationToken canc return current; } - var refreshed = await _refresh(current, token).ConfigureAwait(false) - ?? throw new InvalidOperationException("The credential refresh returned no credential."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(_refreshTimeoutMilliseconds); + GameCredential refreshed; + try + { + refreshed = await CancellableOperation.WaitAsync( + _refresh(current, timeout.Token), + timeout.Token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The credential refresh returned no credential."); + } + catch (OperationCanceledException exception) + when (!token.IsCancellationRequested && timeout.IsCancellationRequested) + { + throw new TimeoutException( + $"The credential refresh exceeded {_refreshTimeoutMilliseconds} ms.", + exception); + } + if (refreshed.IsExpired(_clock(), _refreshSkew)) { throw new InvalidOperationException("The credential refresh returned an expired credential."); @@ -603,8 +801,21 @@ public async ValueTask LoginAsync( throw new InvalidOperationException("The login flow returned an expired credential."); } - using var settlement = new CancellationTokenSource(_credentialCommitTimeoutMilliseconds); - await _store.SetAsync(_key, credential, settlement.Token).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + using var settlement = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + settlement.CancelAfter(_credentialCommitTimeoutMilliseconds); + try + { + await _store.SetAsync(_key, credential, settlement.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when ( + !cancellationToken.IsCancellationRequested && settlement.IsCancellationRequested) + { + throw new TimeoutException( + $"The credential commit exceeded {_credentialCommitTimeoutMilliseconds} ms.", + exception); + } + return credential; } diff --git a/src/OpenGameAgent.Models/Data/model-directory.json b/src/OpenGameAgent.Models/Data/model-directory.json new file mode 100644 index 0000000..4e7bf3f --- /dev/null +++ b/src/OpenGameAgent.Models/Data/model-directory.json @@ -0,0 +1 @@ +{"version":"2026-08-08","generatedAt":"2026-08-08T07:15:52.5961827Z","providers":[{"id":"amazon-bedrock","name":"Amazon Bedrock","endpoint":null,"metadata":{"documentation":"https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html","environmentVariables":"AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_BEARER_TOKEN_BEDROCK"},"models":[{"id":"amazon.nova-2-lite-v1:0","name":"Nova 2 Lite","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.33,"output":2.75,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"nova","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"amazon.nova-lite-v1:0","name":"Nova Lite","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":300000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.06,"output":0.24,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"nova-lite","knowledge":"2024-10","releaseDate":"2024-12-03","lastUpdated":"2024-12-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"amazon.nova-micro-v1:0","name":"Nova Micro","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.035,"output":0.14,"cacheRead":0.00875,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"nova-micro","knowledge":"2024-10","releaseDate":"2024-12-03","lastUpdated":"2024-12-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"amazon.nova-pro-v1:0","name":"Nova Pro","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":300000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.8,"output":3.2,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"nova-pro","knowledge":"2024-10","releaseDate":"2024-12-03","lastUpdated":"2024-12-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"anthropic.claude-fable-5","name":"Claude Fable 5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"anthropic.claude-opus-4-5-20251101-v1:0","name":"Claude Opus 4.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-11-24","lastUpdated":"2025-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"anthropic.claude-opus-4-6-v1","name":"Claude Opus 4.6","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"anthropic.claude-opus-4-7","name":"Claude Opus 4.7","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"anthropic.claude-opus-4-8","name":"Claude Opus 4.8","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"anthropic.claude-opus-5","name":"Claude Opus 5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"anthropic.claude-sonnet-4-6","name":"Claude Sonnet 4.6","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"anthropic.claude-sonnet-5","name":"Claude Sonnet 5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"au.anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5 (AU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"au.anthropic.claude-opus-4-6-v1","name":"AU Anthropic Claude Opus 4.6","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":16.5,"output":82.5,"cacheRead":1.65,"cacheWrite":20.625},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-05","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"au.anthropic.claude-opus-4-8","name":"Claude Opus 4.8 (AU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"au.anthropic.claude-opus-5","name":"Claude Opus 5 (AU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"au.anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5 (AU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"au.anthropic.claude-sonnet-4-6","name":"AU Anthropic Claude Sonnet 4.6","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.3,"output":16.5,"cacheRead":0.33,"cacheWrite":4.125},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-08","releaseDate":"2026-02-17","lastUpdated":"2026-02-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"au.anthropic.claude-sonnet-5","name":"Claude Sonnet 5 (AU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"deepseek.r1-v1:0","name":"DeepSeek-R1","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.35,"output":5.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek reasoning model for multi-step analysis, math, coding, and tools","family":"deepseek-thinking","knowledge":"2024-07","releaseDate":"2025-01-20","lastUpdated":"2025-05-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"deepseek.v3-v1:0","name":"DeepSeek-V3.1","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":163840,"maximumOutput":81920,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.58,"output":1.68,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2024-07","releaseDate":"2025-09-18","lastUpdated":"2025-09-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"deepseek.v3.2","name":"DeepSeek-V3.2","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":163840,"maximumOutput":81920,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.62,"output":1.85,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2024-07","releaseDate":"2026-02-06","lastUpdated":"2026-02-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-fable-5","name":"Claude Fable 5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":11.0,"output":55.0,"cacheRead":1.1,"cacheWrite":13.75},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"eu.anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.1,"output":5.5,"cacheRead":0.11,"cacheWrite":1.375},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-opus-4-5-20251101-v1:0","name":"Claude Opus 4.5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.5,"output":27.5,"cacheRead":0.55,"cacheWrite":6.875},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-11-24","lastUpdated":"2025-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-opus-4-6-v1","name":"Claude Opus 4.6 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.5,"output":27.5,"cacheRead":0.55,"cacheWrite":6.875},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-opus-4-7","name":"Claude Opus 4.7 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.5,"output":27.5,"cacheRead":0.55,"cacheWrite":6.875},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"eu.anthropic.claude-opus-4-8","name":"Claude Opus 4.8 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.5,"output":27.5,"cacheRead":0.55,"cacheWrite":6.875},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"eu.anthropic.claude-opus-5","name":"Claude Opus 5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.5,"output":27.5,"cacheRead":0.55,"cacheWrite":6.875},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"eu.anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.3,"output":16.5,"cacheRead":0.33,"cacheWrite":4.125},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-sonnet-4-6","name":"Claude Sonnet 4.6 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.3,"output":16.5,"cacheRead":0.33,"cacheWrite":4.125},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"eu.anthropic.claude-sonnet-5","name":"Claude Sonnet 5 (EU)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.2,"output":11.0,"cacheRead":0.22,"cacheWrite":2.75},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-fable-5","name":"Claude Fable 5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"global.anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-opus-4-5-20251101-v1:0","name":"Claude Opus 4.5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-11-24","lastUpdated":"2025-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-opus-4-6-v1","name":"Claude Opus 4.6 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-opus-4-7","name":"Claude Opus 4.7 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"global.anthropic.claude-opus-4-8","name":"Claude Opus 4.8 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"global.anthropic.claude-opus-5","name":"Claude Opus 5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"global.anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-sonnet-4-6","name":"Claude Sonnet 4.6 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"global.anthropic.claude-sonnet-5","name":"Claude Sonnet 5 (Global)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"google.gemma-3-27b-it","name":"Google Gemma 3 27B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":202752,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.12,"output":0.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","knowledge":"2025-07","releaseDate":"2025-07-27","lastUpdated":"2025-07-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"google.gemma-3-4b-it","name":"Gemma 3 4B IT","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.04,"output":0.08,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"jp.anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"jp.anthropic.claude-opus-4-7","name":"Claude Opus 4.7 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Stronger Opus tier for advanced software work and high-stakes reasoning","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"jp.anthropic.claude-opus-4-8","name":"Claude Opus 4.8 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"jp.anthropic.claude-opus-5","name":"Claude Opus 5 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"jp.anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"jp.anthropic.claude-sonnet-4-6","name":"Claude Sonnet 4.6 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"jp.anthropic.claude-sonnet-5","name":"Claude Sonnet 5 (JP)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"meta.llama3-1-70b-instruct-v1:0","name":"Llama 3.1 70B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.72,"output":0.72,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12","releaseDate":"2024-07-23","lastUpdated":"2024-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"meta.llama3-1-8b-instruct-v1:0","name":"Llama 3.1 8B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.22,"output":0.22,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12","releaseDate":"2024-07-23","lastUpdated":"2024-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"meta.llama3-3-70b-instruct-v1:0","name":"Llama 3.3 70B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.72,"output":0.72,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2024-12-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"meta.llama4-maverick-17b-instruct-v1:0","name":"Llama 4 Maverick 17B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.24,"output":0.97,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","knowledge":"2024-08","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"meta.llama4-scout-17b-instruct-v1:0","name":"Llama 4 Scout 17B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":3500000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.17,"output":0.66,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","knowledge":"2024-08","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"minimax.minimax-m2","name":"MiniMax M2","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":204608,"maximumOutput":128000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2025-10-27","lastUpdated":"2025-10-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"minimax.minimax-m2.1","name":"MiniMax M2.1","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"minimax.minimax-m2.5","name":"MiniMax M2.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":196608,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"mistral.devstral-2-123b","name":"Devstral 2 123B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":256000,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral coding agent model for repository tasks and software engineering workflows","family":"devstral","releaseDate":"2026-02-17","lastUpdated":"2026-02-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.magistral-small-2509","name":"Magistral Small 1.2","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":40000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral reasoning model for transparent analysis, math, and complex decisions","family":"magistral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.ministral-3-14b-instruct","name":"Ministral 14B 3.0","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":0.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.ministral-3-3b-instruct","name":"Ministral 3 3B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":256000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.1,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.ministral-3-8b-instruct","name":"Ministral 3 8B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.mistral-large-3-675b-instruct","name":"Mistral Large 3","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":256000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.pixtral-large-2502-v1:0","name":"Pixtral Large (25.02)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral vision-language model for image understanding and multimodal chat","family":"mistral","releaseDate":"2025-04-08","lastUpdated":"2025-04-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"mistral.voxtral-mini-3b-2507","name":"Voxtral Mini 3B 2507","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.04,"output":0.04,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"mistral.voxtral-small-24b-2507","name":"Voxtral Small 24B 2507","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":32000,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.35,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral","releaseDate":"2025-07-01","lastUpdated":"2025-07-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"moonshot.kimi-k2-thinking","name":"Kimi K2 Thinking","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262143,"maximumOutput":16000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":true,"supportsStrictMode":true}},{"id":"moonshotai.kimi-k2.5","name":"Kimi K2.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262143,"maximumOutput":16000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi","releaseDate":"2026-02-06","lastUpdated":"2026-02-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":true,"supportsStrictMode":true}},{"id":"nvidia.nemotron-nano-12b-v2","name":"NVIDIA Nemotron Nano 12B v2 VL BF16","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron multimodal model for visual reasoning and agentic AI workflows","family":"nemotron","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"nvidia.nemotron-nano-3-30b","name":"NVIDIA Nemotron Nano 3 30B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.06,"output":0.24,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Small Nemotron 3 MoE for efficient coding, math, and long-context agents","family":"nemotron","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"nvidia.nemotron-nano-9b-v2","name":"NVIDIA Nemotron Nano 9B v2","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.06,"output":0.23,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Nemotron model for efficient reasoning and deployable AI agents","family":"nemotron","releaseDate":"2024-12-01","lastUpdated":"2024-12-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"nvidia.nemotron-super-3-120b","name":"NVIDIA Nemotron 3 Super 120B A12B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.15,"output":0.65,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-5.4","name":"GPT-5.4","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":272000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":2.75,"output":16.5,"cacheRead":0.275,"cacheWrite":0.0},"metadata":{"description":"Agent-ready GPT for coding and computer-use workflows at a lower cost","family":"gpt","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-06-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-5.5","name":"GPT-5.5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":272000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":5.5,"output":33.0,"cacheRead":0.55,"cacheWrite":0.0},"metadata":{"description":"Default frontier GPT for coding, computer use, research, and knowledge work","family":"gpt","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-06-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-5.6-luna","name":"GPT-5.6 Luna","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":272000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.22,"output":1.32,"cacheRead":0.022,"cacheWrite":0.275},"metadata":{"description":"Cost-efficient GPT-5.6 model for fast, high-volume workloads","family":"gpt-luna","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-5.6-sol","name":"GPT-5.6 Sol","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":272000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.5,"output":33.0,"cacheRead":0.55,"cacheWrite":6.88},"metadata":{"description":"Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-5.6-terra","name":"GPT-5.6 Terra","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":272000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.2,"output":13.2,"cacheRead":0.22,"cacheWrite":2.75},"metadata":{"description":"Balanced GPT-5.6 model for capable, cost-efficient everyday work","family":"gpt-terra","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-120b","name":"gpt-oss-120b","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-120b-1:0","name":"gpt-oss-120b","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-20b","name":"gpt-oss-20b","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.07,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-20b-1:0","name":"gpt-oss-20b","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.07,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-safeguard-120b","name":"GPT OSS Safeguard 120B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Safety model for policy screening, moderation, and risk-aware routing workflows","family":"gpt-oss","releaseDate":"2025-10-29","lastUpdated":"2025-10-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"openai.gpt-oss-safeguard-20b","name":"GPT OSS Safeguard 20B","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.07,"output":0.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Safety model for policy screening, moderation, and risk-aware routing workflows","family":"gpt-oss","releaseDate":"2025-10-29","lastUpdated":"2025-10-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-235b-a22b-2507-v1:0","name":"Qwen3 235B A22B 2507","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.22,"output":0.88,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2024-04","releaseDate":"2025-09-18","lastUpdated":"2025-09-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-32b-v1:0","name":"Qwen3 32B (dense)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":16384,"maximumOutput":16383,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2024-04","releaseDate":"2025-09-18","lastUpdated":"2025-09-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-coder-30b-a3b-v1:0","name":"Qwen3 Coder 30B A3B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2024-04","releaseDate":"2025-09-18","lastUpdated":"2025-09-18","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-coder-480b-a35b-v1:0","name":"Qwen3 Coder 480B A35B Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.22,"output":1.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2024-04","releaseDate":"2025-09-18","lastUpdated":"2025-09-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-coder-next","name":"Qwen3 Coder Next","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.22,"output":1.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","releaseDate":"2026-02-06","lastUpdated":"2026-02-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-next-80b-a3b","name":"Qwen/Qwen3-Next-80B-A3B-Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262000,"maximumOutput":261999,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.14,"output":1.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2025-09-18","lastUpdated":"2025-11-25","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"qwen.qwen3-vl-235b-a22b","name":"Qwen/Qwen3-VL-235B-A22B-Instruct","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2025-10-04","lastUpdated":"2025-11-25","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-fable-5","name":"Claude Fable 5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.anthropic.claude-haiku-4-5-20251001-v1:0","name":"Claude Haiku 4.5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-opus-4-5-20251101-v1:0","name":"Claude Opus 4.5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-11-24","lastUpdated":"2025-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-opus-4-6-v1","name":"Claude Opus 4.6 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-opus-4-7","name":"Claude Opus 4.7 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.anthropic.claude-opus-4-8","name":"Claude Opus 4.8 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.anthropic.claude-opus-5","name":"Claude Opus 5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.anthropic.claude-sonnet-4-5-20250929-v1:0","name":"Claude Sonnet 4.5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-sonnet-4-6","name":"Claude Sonnet 4.6 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.anthropic.claude-sonnet-5","name":"Claude Sonnet 5 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStrictMode":true}},{"id":"us.deepseek.r1-v1:0","name":"DeepSeek-R1 (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":128000,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.35,"output":5.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Classic open reasoning model for transparent math, coding, and deliberate problem solving","family":"deepseek-thinking","knowledge":"2024-07","releaseDate":"2025-01-20","lastUpdated":"2025-05-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.meta.llama4-maverick-17b-instruct-v1:0","name":"Llama 4 Maverick 17B Instruct (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.24,"output":0.97,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Llama for strong reasoning with efficient everyday serving","family":"llama","knowledge":"2024-08","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"us.meta.llama4-scout-17b-instruct-v1:0","name":"Llama 4 Scout 17B Instruct (US)","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":3500000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.17,"output":0.66,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama with long-context vision for efficient multimodal agents","family":"llama","knowledge":"2024-08","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"writer.palmyra-x4-v1:0","name":"Palmyra X4","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":122880,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"palmyra","releaseDate":"2025-04-28","lastUpdated":"2025-04-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"writer.palmyra-x5-v1:0","name":"Palmyra X5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1040000,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":6.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"palmyra","releaseDate":"2025-04-28","lastUpdated":"2025-04-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStrictMode":false}},{"id":"xai.grok-4.3","name":"Grok 4.3","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":1000000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":2.5,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"xAI's default Grok for chat, coding, agentic tools, and lower hallucination risk","family":"grok","releaseDate":"2026-04-17","lastUpdated":"2026-06-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"zai.glm-4.7","name":"GLM-4.7","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStrictMode":true}},{"id":"zai.glm-4.7-flash","name":"GLM-4.7-Flash","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.07,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStrictMode":true}},{"id":"zai.glm-5","name":"GLM-5","api":"bedrock-converse-stream","baseUrl":null,"contextWindow":202752,"maximumOutput":101376,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":3.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStrictMode":true}}]},{"id":"anthropic","name":"Anthropic","endpoint":null,"metadata":{"documentation":"https://docs.anthropic.com/en/docs/about-claude/models","environmentVariables":"ANTHROPIC_API_KEY"},"models":[{"id":"claude-fable-5","name":"Claude Fable 5","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","releaseDate":"2026-06-07","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5 (latest)","api":"anthropic-messages","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude lane for lightweight agents, office tasks, and responsive chat","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":false}},{"id":"claude-haiku-4-5-20251001","name":"Claude Haiku 4.5","api":"anthropic-messages","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":false}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5 (latest)","api":"anthropic-messages","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-05","releaseDate":"2025-11-24","lastUpdated":"2025-11-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-opus-4-5-20251101","name":"Claude Opus 4.5","api":"anthropic-messages","baseUrl":null,"contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-05","releaseDate":"2025-11-24","lastUpdated":"2025-11-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-04","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-opus-4-7","name":"Claude Opus 4.7","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Stronger Opus tier for advanced software work and high-stakes reasoning","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-14","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-opus-4-8","name":"Claude Opus 4.8","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-opus-5","name":"Claude Opus 5","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5 (latest)","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-sonnet-4-5-20250929","name":"Claude Sonnet 4.5","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}},{"id":"claude-sonnet-5","name":"Claude Sonnet 5","api":"anthropic-messages","baseUrl":null,"contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-29","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":true,"supportsToolReferences":true}}]},{"id":"baseten","name":"Baseten","endpoint":"https://inference.baseten.co/v1","metadata":{"documentation":"https://docs.baseten.co/inference/model-apis/overview","environmentVariables":"BASETEN_API_KEY"},"models":[{"id":"deepseek-ai/DeepSeek-V4-Flash-0731","name":"Deepseek V4 Flash 0731","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.13,"output":0.26,"cacheRead":0.028,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"deepseek-ai/DeepSeek-V4-Pro","name":"Deepseek V4 Pro","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":1.74,"output":3.48,"cacheRead":0.145,"cacheWrite":0.0},"metadata":{"description":"Open MoE flagship with million-token context for coding and long agent runs","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"moonshotai/Kimi-K2.5","name":"Kimi K2.5","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.6,"output":3.0,"cacheRead":0.12,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-12","releaseDate":"2026-01-30","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"moonshotai/Kimi-K2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"moonshotai/Kimi-K2.7-Code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"moonshotai/Kimi-K3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":1048576,"maximumOutput":262144,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","high","max"],"reasoningValues":{"off":"none","low":"low","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/Nemotron-120B-A12B","name":"Nemotron Super","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":202800,"maximumOutput":202799,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.3,"output":0.75,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","knowledge":"2026-02","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B","name":"Nemotron Ultra","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":202800,"maximumOutput":202799,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.6,"output":2.4,"cacheRead":0.12,"cacheWrite":0.0},"metadata":{"description":"Largest Nemotron 3 model for maximum open-weight reasoning and agent accuracy","family":"nemotron","releaseDate":"2026-06-04","lastUpdated":"2026-06-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"openai/gpt-oss-120b","name":"OpenAI GPT 120B","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":128072,"maximumOutput":128071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.1,"output":0.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","knowledge":"2025-08","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"thinkingmachines/inkling","name":"Inkling","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":1048576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":1.0,"output":4.05,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"ling","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"thinkingmachines/inkling-small","name":"Inkling Small","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":1048576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.5,"output":1.2,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"ling","releaseDate":"2026-07-30","lastUpdated":"2026-07-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"zai-org/GLM-4.7","name":"GLM 4.7","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":200000,"maximumOutput":199999,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.6,"output":2.2,"cacheRead":0.12,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"zai-org/GLM-5","name":"GLM 5","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":202800,"maximumOutput":202799,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":0.95,"output":3.15,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2026-01","releaseDate":"2026-02-12","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"zai-org/GLM-5.1","name":"GLM 5.1","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":202800,"maximumOutput":202799,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"off"},"cost":{"input":1.3,"output":4.3,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Strong GLM coding model for agentic engineering, terminals, and repository generation","family":"glm","releaseDate":"2026-04-07","lastUpdated":"2026-04-07","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"zai-org/GLM-5.2","name":"GLM 5.2","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":1048576,"maximumOutput":262144,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high","max"],"reasoningValues":{"off":"none","high":"high","max":"max"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}},{"id":"zai-org/GLM-5.2-Fast","name":"GLM 5.2 Fast","api":"openai-completions","baseUrl":"https://inference.baseten.co/v1","contextWindow":524288,"maximumOutput":262144,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high","max"],"reasoningValues":{"off":"none","high":"high","max":"max"},"cost":{"input":2.1,"output":6.6,"cacheRead":0.21,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"baseten","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"chatTemplateArgs":{"enable_thinking":{"$var":"thinking.enabled"}}}}]},{"id":"cerebras","name":"Cerebras","endpoint":"https://api.cerebras.ai/v1","metadata":{"documentation":"https://inference-docs.cerebras.ai/models/overview","environmentVariables":"CEREBRAS_API_KEY"},"models":[{"id":"gemma-4-31b","name":"Gemma 4 31B IT","api":"openai-completions","baseUrl":"https://api.cerebras.ai/v1","contextWindow":131072,"maximumOutput":40960,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":0.99,"output":1.49,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Largest Gemma 4 instruction model for open, self-hosted chat and reasoning","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-07-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://api.cerebras.ai/v1","contextWindow":131072,"maximumOutput":40960,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.35,"output":0.75,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2026-06-10","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-glm-4.7","name":"Z.AI GLM-4.7","api":"openai-completions","baseUrl":"https://api.cerebras.ai/v1","contextWindow":131072,"maximumOutput":40960,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off"],"reasoningValues":{"off":"none"},"cost":{"input":2.25,"output":2.75,"cacheRead":2.25,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","releaseDate":"2026-01-07","lastUpdated":"2026-06-10","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"cloudflare-ai-gateway","name":"Cloudflare AI Gateway","endpoint":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","metadata":{"documentation":"https://developers.cloudflare.com/ai-gateway/","environmentVariables":"CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_GATEWAY_ID"},"models":[{"id":"anthropic/claude-3-5-haiku","name":"Claude Haiku 3.5 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.8,"output":4.0,"cacheRead":0.08,"cacheWrite":1.0},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2024-07-31","releaseDate":"2024-10-22","lastUpdated":"2024-10-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-3-haiku","name":"Claude Haiku 3","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.25,"output":1.25,"cacheRead":0.03,"cacheWrite":0.3},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2023-08-31","releaseDate":"2024-03-13","lastUpdated":"2024-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-3-opus","name":"Claude Opus 3","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":15.0,"output":75.0,"cacheRead":1.5,"cacheWrite":18.75},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2023-08-31","releaseDate":"2024-02-29","lastUpdated":"2024-02-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-3-sonnet","name":"Claude Sonnet 3","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.3},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2023-08-31","releaseDate":"2024-03-04","lastUpdated":"2024-03-04","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-3.5-haiku","name":"Claude Haiku 3.5 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.8,"output":4.0,"cacheRead":0.08,"cacheWrite":1.0},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2024-07-31","releaseDate":"2024-10-22","lastUpdated":"2024-10-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-3.5-sonnet","name":"Claude Sonnet 3.5 v2","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2024-04-30","releaseDate":"2024-10-22","lastUpdated":"2024-10-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-fable-5","name":"Claude Fable 5","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-haiku-4-5","name":"Claude Haiku 4.5 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4","name":"Claude Opus 4 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":32000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":15.0,"output":75.0,"cacheRead":1.5,"cacheWrite":18.75},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-05-22","lastUpdated":"2025-05-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4-1","name":"Claude Opus 4.1 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":32000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":15.0,"output":75.0,"cacheRead":1.5,"cacheWrite":18.75},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4-5","name":"Claude Opus 4.5 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-11-24","lastUpdated":"2025-11-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4-6","name":"Claude Opus 4.6 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":200000,"input":10.0,"output":37.5,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4-7","name":"Claude Opus 4.7","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-4-8","name":"Claude Opus 4.8","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-opus-5","name":"Claude Opus 5","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Strongest Claude Opus model for coding, agents, and professional work","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-sonnet-4","name":"Claude Sonnet 4 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-03-31","releaseDate":"2025-05-22","lastUpdated":"2025-05-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-sonnet-4-5","name":"Claude Sonnet 4.5 (latest)","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-sonnet-4-6","name":"Claude Sonnet 4.6","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75,"tiers":[{"above":200000,"input":6.0,"output":22.5,"cacheRead":0.6,"cacheWrite":7.5}]},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-02-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"anthropic/claude-sonnet-5","name":"Claude Sonnet 5","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"interleaved":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"moonshotai/kimi-k3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["max"],"reasoningValues":{"max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-4","name":"GPT-4","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":8192,"maximumOutput":8191,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":30.0,"output":60.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-11","releaseDate":"2023-11-06","lastUpdated":"2024-04-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-4-turbo","name":"GPT-4 Turbo","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":10.0,"output":30.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2023-12","releaseDate":"2023-11-06","lastUpdated":"2024-04-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-4o","name":"GPT-4o","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-05-13","lastUpdated":"2024-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-4o-mini","name":"GPT-4o mini","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.08,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt-mini","knowledge":"2023-09","releaseDate":"2024-07-18","lastUpdated":"2024-07-18","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.1","name":"GPT-5.1","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.13,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.1-codex","name":"GPT-5.1 Codex","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.2","name":"GPT-5.2","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.2-codex","name":"GPT-5.2 Codex","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.3-codex","name":"GPT-5.3 Codex","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.4","name":"GPT-5.4","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0},"metadata":{"description":"Frontier GPT model for professional reasoning, coding, and multimodal work","family":"gpt","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-03-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.5","name":"GPT-5.5","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":0.0}]},"metadata":{"description":"Default frontier GPT for coding, computer use, research, and knowledge work","family":"gpt","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.6-luna","name":"GPT-5.6 Luna","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.0,"output":6.0,"cacheRead":0.1,"cacheWrite":0.0,"tiers":[{"above":272000,"input":2.0,"output":9.0,"cacheRead":0.2,"cacheWrite":0.0}]},"metadata":{"description":"Cost-efficient GPT-5.6 model for fast, high-volume workloads","family":"gpt-luna","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":0.0}]},"metadata":{"description":"Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-5.6-terra","name":"GPT-5.6 Terra","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0,"tiers":[{"above":272000,"input":5.0,"output":22.5,"cacheRead":0.5,"cacheWrite":0.0}]},"metadata":{"description":"Balanced GPT-5.6 model for capable, cost-efficient everyday work","family":"gpt-terra","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/o1","name":"o1","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":15.0,"output":60.0,"cacheRead":7.5,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o","knowledge":"2023-09","releaseDate":"2024-12-05","lastUpdated":"2024-12-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/o3","name":"o3","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":8.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o","knowledge":"2024-05","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/o3-mini","name":"o3-mini","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":100000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o-mini","knowledge":"2024-05","releaseDate":"2024-12-20","lastUpdated":"2025-01-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/o3-pro","name":"o3-pro","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":20.0,"output":80.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o-pro","knowledge":"2024-05","releaseDate":"2025-06-10","lastUpdated":"2025-06-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/o4-mini","name":"o4-mini","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.28,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o-mini","knowledge":"2024-05","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"workers-ai/@cf/moonshotai/kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.6,"output":3.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-01-27","lastUpdated":"2026-01-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"workers-ai/@cf/moonshotai/kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-20","lastUpdated":"2026-04-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"workers-ai/@cf/nvidia/nemotron-3-120b-a12b","name":"Nemotron 3 Super 120B","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":256000,"maximumOutput":255999,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"workers-ai/@cf/zai-org/glm-4.7-flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.06,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"workers-ai/@cf/zai-org/glm-5.2","name":"Glm 5.2","api":"openai-completions","baseUrl":"https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}}]},{"id":"cloudflare-workers-ai","name":"Cloudflare Workers AI","endpoint":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","metadata":{"documentation":"https://developers.cloudflare.com/workers-ai/models/","environmentVariables":"CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_KEY"},"models":[{"id":"@cf/google/gemma-4-26b-a4b-it","name":"Gemma 4 26B A4B IT","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":256000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/ibm-granite/granite-4.0-h-micro","name":"Granite 4.0 H Micro","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":131000,"maximumOutput":130999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.017,"output":0.112,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"granite","releaseDate":"2025-10-07","lastUpdated":"2025-10-07","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/meta/llama-3.3-70b-instruct-fp8-fast","name":"Llama 3.3 70B Instruct fp8 Fast","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":24000,"maximumOutput":23999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.293,"output":2.253,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Popular open Llama workhorse for multilingual chat, coding, and self-hosting","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2024-12-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/meta/llama-4-scout-17b-16e-instruct","name":"Llama 4 Scout 17B 16E Instruct","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":131000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.27,"output":0.85,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama with long-context vision for efficient multimodal agents","family":"llama","knowledge":"2024-08","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/mistralai/mistral-small-3.1-24b-instruct","name":"Mistral Small 3.1 24B Instruct","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.351,"output":0.555,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral-small","releaseDate":"2025-03-18","lastUpdated":"2025-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/moonshotai/kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":262144,"maximumOutput":256000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi workhorse for agent loops, coding tasks, and visual context","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/moonshotai/kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.95,"output":4.0,"cacheRead":0.19,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/nvidia/nemotron-3-120b-a12b","name":"Nemotron 3 Super 120B","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":256000,"maximumOutput":255999,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/openai/gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.35,"output":0.75,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/qwen/qwen3-30b-a3b-fp8","name":"Qwen3 30B A3b fp8","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":32768,"maximumOutput":32767,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0509,"output":0.335,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2025-04-30","lastUpdated":"2025-04-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/zai-org/glm-4.7-flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.0605,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"@cf/zai-org/glm-5.2","name":"Glm 5.2","api":"openai-completions","baseUrl":"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}}]},{"id":"deepseek","name":"DeepSeek","endpoint":"https://api.deepseek.com","metadata":{"documentation":"https://api-docs.deepseek.com/quick_start/pricing","environmentVariables":"DEEPSEEK_API_KEY"},"models":[{"id":"deepseek-chat","name":"DeepSeek Chat","api":"openai-completions","baseUrl":"https://api.deepseek.com","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0028,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2025-09","releaseDate":"2025-12-01","lastUpdated":"2026-02-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-reasoner","name":"DeepSeek Reasoner","api":"openai-completions","baseUrl":"https://api.deepseek.com","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0028,"cacheWrite":0.0},"metadata":{"description":"DeepSeek reasoning model for multi-step analysis, math, coding, and tools","family":"deepseek-thinking","knowledge":"2025-09","releaseDate":"2025-12-01","lastUpdated":"2026-02-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","api":"openai-completions","baseUrl":"https://api.deepseek.com","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0028,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","api":"openai-completions","baseUrl":"https://api.deepseek.com","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.435,"output":0.87,"cacheRead":0.003625,"cacheWrite":0.0},"metadata":{"description":"Open MoE flagship with million-token context for coding and long agent runs","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"fireworks-ai","name":"Fireworks AI","endpoint":"https://api.fireworks.ai/inference/v1/","metadata":{"documentation":"https://fireworks.ai/docs/","environmentVariables":"FIREWORKS_API_KEY"},"models":[{"id":"accounts/fireworks/models/deepseek-v4-flash","name":"DeepSeek V4 Flash","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.028,"cacheWrite":0.0},"metadata":{"description":"Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/deepseek-v4-flash-0731","name":"DeepSeek V4 Flash 0731","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.028,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/deepseek-v4-pro","name":"DeepSeek V4 Pro","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":1.74,"output":3.48,"cacheRead":0.145,"cacheWrite":0.0},"metadata":{"description":"Open MoE flagship with million-token context for coding and long agent runs","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/glm-5p2","name":"GLM 5.2","api":"openai-completions","baseUrl":"https://api.fireworks.ai/inference/v1","contextWindow":1048575,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"off":"none","low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.14,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","releaseDate":"2026-06-16","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"accounts/fireworks/models/gpt-oss-120b","name":"GPT OSS 120B","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/gpt-oss-20b","name":"GPT OSS 20B","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.07,"output":0.3,"cacheRead":0.035,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/kimi-k2p6","name":"Kimi K2.6","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/kimi-k2p7-code","name":"Kimi K2.7 Code","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.19,"cacheWrite":0.0},"metadata":{"description":"Kimi coding model for software agents, refactors, and repository reasoning","family":"kimi-k2","releaseDate":"2026-06-12","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/kimi-k3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://api.fireworks.ai/inference/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-27","lastUpdated":"2026-07-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"deferredToolsMode":"kimi"}},{"id":"accounts/fireworks/models/minimax-m2p7","name":"MiniMax-M2.7","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":196608,"maximumOutput":196607,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-04-12","lastUpdated":"2026-04-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/minimax-m3","name":"MiniMax-M3","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":512000,"maximumOutput":511999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax multimodal coding model for long-context reasoning and agent tasks","family":"minimax","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/models/qwen3p7-plus","name":"Qwen 3.7 Plus","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.4,"output":1.6,"cacheRead":0.08,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/routers/glm-5p2-fast","name":"GLM 5.2 Fast","api":"openai-completions","baseUrl":"https://api.fireworks.ai/inference/v1","contextWindow":1048575,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"off":"none","low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":2.1,"output":6.6,"cacheRead":0.21,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm","releaseDate":"2026-06-26","lastUpdated":"2026-06-26","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"accounts/fireworks/routers/kimi-k2p6-fast","name":"Kimi K2.6 Fast","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":8.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","releaseDate":"2026-04-17","lastUpdated":"2026-06-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/routers/kimi-k2p6-turbo","name":"Kimi K2.6 Turbo","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":8.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/routers/kimi-k2p7-code-fast","name":"Kimi K2.7 Code Fast","api":"anthropic-messages","baseUrl":"https://api.fireworks.ai/inference","contextWindow":262000,"maximumOutput":261999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.9,"output":8.0,"cacheRead":0.38,"cacheWrite":0.0},"metadata":{"description":"Kimi coding model for software agents, refactors, and repository reasoning","family":"kimi-k2","releaseDate":"2026-06-12","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsEagerToolInputStreaming":false,"supportsLongCacheRetention":false,"sendSessionAffinityHeaders":true,"supportsCacheControlOnTools":false,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"accounts/fireworks/routers/kimi-k3-fast","name":"Kimi K3 Fast","api":"openai-completions","baseUrl":"https://api.fireworks.ai/inference/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":4.5,"output":22.5,"cacheRead":0.45,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-27","lastUpdated":"2026-07-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":true,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false,"deferredToolsMode":"kimi"}}]},{"id":"google","name":"Google","endpoint":null,"metadata":{"documentation":"https://ai.google.dev/gemini-api/docs/models","environmentVariables":"GOOGLE_API_KEY,GOOGLE_GENERATIVE_AI_API_KEY,GEMINI_API_KEY"},"models":[{"id":"deep-research-max-preview-04-2026","name":"Deep Research Max Preview (Apr-21-2026)","api":"google-generative-ai","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Maximum-comprehensiveness agentic researcher for multi-step investigation, synthesis, and cited reports","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"deep-research-preview-04-2026","name":"Deep Research Preview (Apr-21-2026)","api":"google-generative-ai","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Agentic model for autonomous multi-step research, synthesis, and cited reports","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-computer-use-preview-10-2025","name":"Gemini 2.5 Computer Use Preview 10-2025","api":"google-generative-ai","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Specialized Gemini 2.5 model for browser-control agents that automate UI tasks","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2025-10-07","lastUpdated":"2025-10-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini workhorse for multimodal apps where latency and price matter","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash-Lite","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.4,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Lean Gemini 2.5 lane for cheap multimodal traffic and quick agents","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0}]},"metadata":{"description":"Google's proven reasoning model for coding, math, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3-flash-preview","name":"Gemini 3 Flash Preview","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.5,"output":3.0,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"New Gemini flash lane bringing frontier-style multimodal reasoning to cheaper runs","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-12-17","lastUpdated":"2025-12-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-flash-lite","name":"Gemini 3.1 Flash Lite","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-05-07","lastUpdated":"2026-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-flash-lite-image","name":"Nano Banana 2 Lite","api":"google-generative-ai","baseUrl":null,"contextWindow":65536,"maximumOutput":65535,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["minimal","high"],"reasoningValues":{"minimal":"minimal","high":"high"},"cost":{"input":0.25,"output":30.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Fastest, most cost-efficient Gemini image model for high-volume 1K generation and editing","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-flash-live-preview","name":"Gemini 3.1 Flash Live Preview","api":"google-generative-ai","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.75,"output":4.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"High-quality, low-latency Live API model for real-time dialogue and voice-first AI applications","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-03-26","lastUpdated":"2026-03-26","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high"],"reasoningValues":{"low":"LOW","high":"HIGH"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Reasoning-first Gemini preview for agentic coding and complex problem solving","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-pro-preview-customtools","name":"Gemini 3.1 Pro Preview Custom Tools","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high"],"reasoningValues":{"low":"LOW","high":"HIGH"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":9.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-05-19","lastUpdated":"2026-05-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.5-flash-lite","name":"Gemini 3.5 Flash Lite","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash-lite","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.6-flash","name":"Gemini 3.6 Flash","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-flash-latest","name":"Gemini Flash Latest","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":9.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-05-19","lastUpdated":"2026-05-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-flash-lite-latest","name":"Gemini Flash-Lite Latest","api":"google-generative-ai","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-05-07","lastUpdated":"2026-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-robotics-er-1.6-preview","name":"Gemini Robotics-ER 1.6 Preview","api":"google-generative-ai","baseUrl":null,"contextWindow":131072,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Vision-language model for embodied reasoning: spatial understanding, task planning, and physical-world agentic robotics","family":"gemini","knowledge":"2025-01","releaseDate":"2026-04-14","lastUpdated":"2026-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemma-4-26b-a4b-it","name":"Gemma 4 26B A4B IT","api":"google-generative-ai","baseUrl":null,"contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","high"],"reasoningValues":{"minimal":"MINIMAL","high":"HIGH"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemma-4-31b-it","name":"Gemma 4 31B IT","api":"google-generative-ai","baseUrl":null,"contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","high"],"reasoningValues":{"minimal":"MINIMAL","high":"HIGH"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Largest Gemma 4 instruction model for open, self-hosted chat and reasoning","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}}]},{"id":"google-vertex","name":"Vertex","endpoint":null,"metadata":{"documentation":"https://cloud.google.com/vertex-ai/generative-ai/docs/models","environmentVariables":"GOOGLE_VERTEX_PROJECT,GOOGLE_VERTEX_LOCATION,GOOGLE_APPLICATION_CREDENTIALS"},"models":[{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":2.5,"cacheRead":0.075,"cacheWrite":0.383},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash-Lite","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.4,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Lean Gemini 2.5 lane for cheap multimodal traffic and quick agents","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0}]},"metadata":{"description":"Google's proven reasoning model for coding, math, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3-flash-preview","name":"Gemini 3 Flash Preview","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.5,"output":3.0,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"New Gemini flash lane bringing frontier-style multimodal reasoning to cheaper runs","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-12-17","lastUpdated":"2025-12-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-flash-lite","name":"Gemini 3.1 Flash Lite","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-05-07","lastUpdated":"2026-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high"],"reasoningValues":{"low":"LOW","high":"HIGH"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Reasoning-first Gemini preview for agentic coding and complex problem solving","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.1-pro-preview-customtools","name":"Gemini 3.1 Pro Preview Custom Tools","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high"],"reasoningValues":{"low":"LOW","high":"HIGH"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":9.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-05-19","lastUpdated":"2026-05-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.5-flash-lite","name":"Gemini 3.5 Flash Lite","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash-lite","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-3.6-flash","name":"Gemini 3.6 Flash","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-flash-latest","name":"Gemini Flash Latest","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":9.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-05-19","lastUpdated":"2026-05-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"useLegacyOpenApiToolSchemas":false}},{"id":"gemini-flash-lite-latest","name":"Gemini Flash-Lite Latest","api":"google-vertex","baseUrl":null,"contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-05-07","lastUpdated":"2026-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"useLegacyOpenApiToolSchemas":false}}]},{"id":"groq","name":"Groq","endpoint":"https://api.groq.com/openai/v1","metadata":{"documentation":"https://console.groq.com/docs/models","environmentVariables":"GROQ_API_KEY"},"models":[{"id":"llama-3.1-8b-instant","name":"Llama 3.1 8B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.05,"output":0.08,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","knowledge":"2023-12","releaseDate":"2024-07-23","lastUpdated":"2024-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"llama-3.3-70b-versatile","name":"Llama 3.3 70B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.59,"output":0.79,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2024-12-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-10-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.075,"output":0.3,"cacheRead":0.0375,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-09-25","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-safeguard-20b","name":"Safety GPT OSS 20B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.075,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Safety model for policy screening, moderation, and risk-aware routing workflows","family":"gpt-oss","releaseDate":"2025-10-29","lastUpdated":"2026-06-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-27b","name":"Qwen3.6 27B","api":"openai-completions","baseUrl":"https://api.groq.com/openai/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"default"},"cost":{"input":0.6,"output":3.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"huggingface","name":"Hugging Face","endpoint":"https://router.huggingface.co/v1","metadata":{"documentation":"https://huggingface.co/docs/inference-providers","environmentVariables":"HF_TOKEN"},"models":[{"id":"deepseek-ai/DeepSeek-R1","name":"DeepSeek-R1","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":64000,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.7,"output":2.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Classic open reasoning model for transparent math, coding, and deliberate problem solving","family":"deepseek-thinking","knowledge":"2024-07","releaseDate":"2025-01-20","lastUpdated":"2025-05-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-R1-0528","name":"DeepSeek-R1-0528","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":163840,"maximumOutput":163839,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":5.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek reasoning model for multi-step analysis, math, coding, and tools","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2025-05-28","lastUpdated":"2025-05-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V3","name":"DeepSeek-V3","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":64000,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":1.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","releaseDate":"2024-12-26","lastUpdated":"2024-12-26","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V3.1","name":"DeepSeek-V3.1","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.27,"output":1.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","releaseDate":"2025-08-21","lastUpdated":"2025-08-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V3.2","name":"DeepSeek-V3.2","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":163840,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.28,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2024-07","releaseDate":"2025-12-01","lastUpdated":"2025-12-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V4-Flash","name":"DeepSeek V4 Flash","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1048576,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V4-Flash-0731","name":"DeepSeek V4 Flash 0731","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1048576,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"deepseek-ai/DeepSeek-V4-Pro","name":"DeepSeek V4 Pro","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1048576,"maximumOutput":393216,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{"high":"high"},"cost":{"input":0.435,"output":0.87,"cacheRead":0.003625,"cacheWrite":0.0},"metadata":{"description":"Open MoE flagship with million-token context for coding and long agent runs","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"google/gemma-4-26B-A4B-it","name":"Gemma 4 26B A4B IT","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.13,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"google/gemma-4-31B-it","name":"Gemma 4 31B IT","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Largest Gemma 4 instruction model for open, self-hosted chat and reasoning","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"meta-llama/Llama-3.3-70B-Instruct","name":"Llama-3.3-70B-Instruct","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.59,"output":0.79,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Popular open Llama workhorse for multilingual chat, coding, and self-hosting","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2024-12-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"MiniMaxAI/MiniMax-M2","name":"MiniMax-M2","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":128000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient open MiniMax model built for coding agents and tool-heavy workflows","family":"minimax","releaseDate":"2025-10-27","lastUpdated":"2025-10-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"MiniMaxAI/MiniMax-M2.1","name":"MiniMax-M2.1","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","knowledge":"2025-10","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"MiniMaxAI/MiniMax-M2.5","name":"MiniMax-M2.5","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-02-12","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"MiniMaxAI/MiniMax-M2.7","name":"MiniMax-M2.7","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"MiniMaxAI/MiniMax-M3","name":"MiniMax-M3","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":524288,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax multimodal model for long-context coding, perception, and agent planning","family":"minimax","releaseDate":"2026-06-01","lastUpdated":"2026-06-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2-Instruct","name":"Kimi-K2-Instruct","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.0,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-07-14","lastUpdated":"2025-07-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2-Instruct-0905","name":"Kimi-K2-Instruct-0905","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.0,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-09-04","lastUpdated":"2025-09-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2-Thinking","name":"Kimi-K2-Thinking","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2.5","name":"Kimi-K2.5","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-01-01","lastUpdated":"2026-01-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2.6","name":"Kimi-K2.6","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-20","lastUpdated":"2026-04-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K2.7-Code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"moonshotai/Kimi-K3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1000000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":0.69,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.1,"output":0.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-235B-A22B","name":"Qwen3 235B-A22B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":40960,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":0.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Large open Qwen MoE for multilingual reasoning, coding, and tool use","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-235B-A22B-Instruct-2507","name":"Qwen3 235B-A22B Instruct 2507","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.855,"output":2.565,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2025-07-21","lastUpdated":"2025-07-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-235B-A22B-Thinking-2507","name":"Qwen3-235B-A22B-Thinking-2507","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","knowledge":"2025-04","releaseDate":"2025-07-25","lastUpdated":"2025-07-25","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-32B","name":"Qwen3 32B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.29,"output":0.59,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Dense open Qwen model for self-hosted chat, reasoning, and coding","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-Coder-30B-A3B-Instruct","name":"Qwen3-Coder 30B-A3B Instruct","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.07,"output":0.26,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Smaller Qwen coder for efficient local agents and repo-level fixes","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-Coder-480B-A35B-Instruct","name":"Qwen3-Coder-480B-A35B-Instruct","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":66536,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-04","releaseDate":"2025-07-23","lastUpdated":"2025-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-Coder-Next","name":"Qwen3-Coder-Next","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-04","releaseDate":"2026-02-03","lastUpdated":"2026-02-03","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-Next-80B-A3B-Instruct","name":"Qwen3-Next-80B-A3B-Instruct","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":66536,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.25,"output":1.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-04","releaseDate":"2025-09-11","lastUpdated":"2025-09-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3-Next-80B-A3B-Thinking","name":"Qwen3-Next-80B-A3B-Thinking","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","knowledge":"2025-04","releaseDate":"2025-09-11","lastUpdated":"2025-09-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.5-122B-A10B","name":"Qwen3.5 122B-A10B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.4,"output":3.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.5-27B","name":"Qwen3.5 27B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":2.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.5-35B-A3B","name":"Qwen3.5 35B-A3B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.25,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.5-397B-A17B","name":"Qwen3.5-397B-A17B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":0.6,"output":3.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-04","releaseDate":"2026-02-01","lastUpdated":"2026-02-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.5-9B","name":"Qwen3.5 9B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.17,"output":0.25,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.6-27B","name":"Qwen3.6 27B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.47,"output":3.19,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"Qwen/Qwen3.6-35B-A3B","name":"Qwen3.6 35B-A3B","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.15,"output":0.95,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Qwen MoE for local agents that need vision, audio, and code","family":"qwen","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"stepfun-ai/Step-3.5-Flash","name":"Step 3.5 Flash","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":256000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"StepFun flash lane for quick multimodal reasoning and coding assistance","knowledge":"2025-01","releaseDate":"2026-01-29","lastUpdated":"2026-02-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"stepfun-ai/Step-3.7-Flash","name":"Step 3.7 Flash","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":256000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.2,"output":1.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Newer StepFun flash model for faster agents, coding, and multimodal prompts","knowledge":"2026-03-01","releaseDate":"2026-05-29","lastUpdated":"2026-05-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"tencent/Hy3","name":"Hy3","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":64000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","high"],"reasoningValues":{"off":"none","low":"low","high":"high"},"cost":{"input":0.14,"output":0.58,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","releaseDate":"2026-07-06","lastUpdated":"2026-07-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"thinkingmachines/Inkling","name":"Inkling","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.0,"output":4.05,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal model for analyzing text, images, documents, and rich media","family":"ling","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"thinkingmachines/Inkling-Small","name":"Inkling Small","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":524288,"maximumOutput":524287,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"ling","releaseDate":"2026-07-30","lastUpdated":"2026-07-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"XiaomiMiMo/MiMo-V2-Flash","name":"MiMo-V2-Flash","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiMo flash model for fast multimodal assistance and agent workflows","family":"mimo","knowledge":"2024-12","releaseDate":"2025-12-16","lastUpdated":"2025-12-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.4,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiMo model for long-context reasoning, perception, and agentic tasks","family":"mimo","knowledge":"2024-12","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.0,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","knowledge":"2024-12","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.5","name":"GLM-4.5","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Hybrid-reasoning GLM release that made the 4.5 line broadly useful","family":"glm","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.5-Air","name":"GLM-4.5-Air","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.13,"output":0.85,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Lighter GLM-4.5 variant for fast coding assistance and cheaper agents","family":"glm-air","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.5V","name":"GLM-4.5V","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":65536,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":1.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-08-11","lastUpdated":"2025-08-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.6","name":"GLM-4.6","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.55,"output":2.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Late GLM-4 workhorse for coding agents, reasoning, and structured tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-09-30","lastUpdated":"2025-09-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-4.7-Flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":200000,"maximumOutput":128000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm","knowledge":"2025-04","releaseDate":"2025-08-08","lastUpdated":"2025-08-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-5","name":"GLM-5","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":202752,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":3.2,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","releaseDate":"2026-02-11","lastUpdated":"2026-02-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-5.1","name":"GLM-5.1","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":202752,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":3.2,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","releaseDate":"2026-04-03","lastUpdated":"2026-04-03","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"zai-org/GLM-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://router.huggingface.co/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.4,"output":4.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"kimi-for-coding","name":"Kimi For Coding","endpoint":"https://api.kimi.com/coding/v1","metadata":{"documentation":"https://www.kimi.com/code/docs/en/third-party-tools/other-coding-agents.html","environmentVariables":"KIMI_API_KEY"},"models":[{"id":"k3","name":"Kimi K3","api":"anthropic-messages","baseUrl":"https://api.kimi.com/coding/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{"User-Agent":"KimiCLI/1.5"},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":true,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"k3-256k","name":"Kimi K3-256K","api":"anthropic-messages","baseUrl":"https://api.kimi.com/coding/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"256K-context version of Kimi K3, reducing token consumption for shorter coding sessions","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{"User-Agent":"KimiCLI/1.5"},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"kimi-for-coding","name":"Kimi K2.7 Code","api":"anthropic-messages","baseUrl":"https://api.kimi.com/coding/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{"User-Agent":"KimiCLI/1.5"},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":true,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"kimi-for-coding-highspeed","name":"Kimi For Coding HighSpeed","api":"anthropic-messages","baseUrl":"https://api.kimi.com/coding/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Lower-latency Kimi Code variant for interactive edits and coding-agent loops","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{"User-Agent":"KimiCLI/1.5"},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":true,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}}]},{"id":"minimax","name":"MiniMax (minimax.io)","endpoint":"https://api.minimax.io/anthropic/v1","metadata":{"documentation":"https://platform.minimax.io/docs/guides/quickstart","environmentVariables":"MINIMAX_API_KEY"},"models":[{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","api":"anthropic-messages","baseUrl":"https://api.minimax.io/anthropic/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.375},"metadata":{"description":"Open MiniMax flagship for coding agents, office automation, and complex environments","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","api":"anthropic-messages","baseUrl":"https://api.minimax.io/anthropic/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.4,"cacheRead":0.06,"cacheWrite":0.375},"metadata":{"description":"Low-latency M2.7 variant for interactive coding plans and agent loops","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"MiniMax-M3","name":"MiniMax-M3","api":"anthropic-messages","baseUrl":"https://api.minimax.io/anthropic/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0,"tiers":[{"above":512000,"input":0.6,"output":2.4,"cacheRead":0.12,"cacheWrite":0.0}]},"metadata":{"description":"MiniMax multimodal model for long-context coding, perception, and agent planning","family":"minimax","releaseDate":"2026-06-01","lastUpdated":"2026-06-25","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}}]},{"id":"minimax-cn","name":"MiniMax (minimaxi.com)","endpoint":"https://api.minimaxi.com/anthropic/v1","metadata":{"documentation":"https://platform.minimaxi.com/docs/guides/quickstart","environmentVariables":"MINIMAX_API_KEY"},"models":[{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","api":"anthropic-messages","baseUrl":"https://api.minimaxi.com/anthropic/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.375},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","api":"anthropic-messages","baseUrl":"https://api.minimaxi.com/anthropic/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.4,"cacheRead":0.06,"cacheWrite":0.375},"metadata":{"description":"High-speed MiniMax model for low-latency coding and agent workflows","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}},{"id":"MiniMax-M3","name":"MiniMax-M3","api":"anthropic-messages","baseUrl":"https://api.minimaxi.com/anthropic/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0,"tiers":[{"above":512000,"input":0.6,"output":2.4,"cacheRead":0.12,"cacheWrite":0.0}]},"metadata":{"description":"MiniMax multimodal coding model for long-context reasoning and agent tasks","family":"minimax","releaseDate":"2026-06-01","lastUpdated":"2026-06-25","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsEagerToolInputStreaming":true,"supportsLongCacheRetention":true,"sendSessionAffinityHeaders":false,"supportsCacheControlOnTools":true,"forceAdaptiveThinking":false,"allowEmptySignature":false,"supportsStrictTools":false,"supportsToolReferences":false}}]},{"id":"mistral","name":"Mistral","endpoint":null,"metadata":{"documentation":"https://docs.mistral.ai/getting-started/models/","environmentVariables":"MISTRAL_API_KEY"},"models":[{"id":"codestral-latest","name":"Codestral (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":256000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral code model for completions, refactors, and developer IDE workflows","family":"codestral","knowledge":"2024-10","releaseDate":"2024-05-29","lastUpdated":"2025-01-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"magistral-medium-latest","name":"Magistral Medium (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":5.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral reasoning model for transparent analysis, math, and complex decisions","family":"magistral-medium","knowledge":"2025-06","releaseDate":"2025-03-17","lastUpdated":"2025-03-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"magistral-small","name":"Magistral Small","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral reasoning model for transparent analysis, math, and complex decisions","family":"magistral-small","knowledge":"2025-06","releaseDate":"2025-03-17","lastUpdated":"2025-03-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"ministral-3b-latest","name":"Ministral 3B (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.04,"output":0.04,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","knowledge":"2024-10","releaseDate":"2024-10-01","lastUpdated":"2024-10-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"ministral-8b-latest","name":"Ministral 8B (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.1,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","knowledge":"2024-10","releaseDate":"2024-10-01","lastUpdated":"2024-10-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-large-2411","name":"Mistral Large 2.1","api":"mistral-conversations","baseUrl":null,"contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral-large","knowledge":"2024-11","releaseDate":"2024-11-18","lastUpdated":"2024-11-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-large-2512","name":"Mistral Large 3","api":"mistral-conversations","baseUrl":null,"contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral's largest general model for enterprise agents, coding, and multilingual reasoning","family":"mistral-large","knowledge":"2024-11","releaseDate":"2024-11-01","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-large-latest","name":"Mistral Large (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral-large","knowledge":"2024-11","releaseDate":"2024-11-01","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-medium-2505","name":"Mistral Medium 3","api":"mistral-conversations","baseUrl":null,"contextWindow":131072,"maximumOutput":131071,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral-medium","knowledge":"2025-05","releaseDate":"2025-05-07","lastUpdated":"2025-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-medium-2508","name":"Mistral Medium 3.1","api":"mistral-conversations","baseUrl":null,"contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral-medium","knowledge":"2025-05","releaseDate":"2025-08-12","lastUpdated":"2025-08-12","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-medium-2604","name":"Mistral Medium 3.5","api":"mistral-conversations","baseUrl":null,"contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Balanced Mistral model for enterprise assistants, multilingual work, and tools","family":"mistral-medium","releaseDate":"2026-04-29","lastUpdated":"2026-04-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true}},{"id":"mistral-medium-latest","name":"Mistral Medium (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Balanced Mistral model for enterprise assistants, multilingual work, and tools","family":"mistral-medium","releaseDate":"2026-04-29","lastUpdated":"2026-04-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true}},{"id":"mistral-nemo","name":"Mistral Nemo","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral-NVIDIA open model for multilingual chat and local deployment","family":"mistral-nemo","knowledge":"2024-07","releaseDate":"2024-07-01","lastUpdated":"2024-07-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-small-2506","name":"Mistral Small 3.2","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral-small","knowledge":"2025-03","releaseDate":"2025-06-20","lastUpdated":"2025-06-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-small-2603","name":"Mistral Small 4","api":"mistral-conversations","baseUrl":null,"contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Fast Mistral production model for chat, extraction, and cost-sensitive agents","family":"mistral-small","knowledge":"2025-06","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"mistral-small-latest","name":"Mistral Small (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral-small","knowledge":"2025-06","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"open-mistral-7b","name":"Mistral 7B","api":"mistral-conversations","baseUrl":null,"contextWindow":8000,"maximumOutput":7999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.25,"output":0.25,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral","knowledge":"2023-12","releaseDate":"2023-09-27","lastUpdated":"2023-09-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"open-mixtral-8x22b","name":"Mixtral 8x22B","api":"mistral-conversations","baseUrl":null,"contextWindow":64000,"maximumOutput":63999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mixtral","knowledge":"2024-04","releaseDate":"2024-04-17","lastUpdated":"2024-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"open-mixtral-8x7b","name":"Mixtral 8x7B","api":"mistral-conversations","baseUrl":null,"contextWindow":32000,"maximumOutput":31999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.7,"output":0.7,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mixtral","knowledge":"2024-01","releaseDate":"2023-12-11","lastUpdated":"2023-12-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"pixtral-12b","name":"Pixtral 12B","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral vision-language model for image understanding and multimodal chat","family":"pixtral","knowledge":"2024-09","releaseDate":"2024-09-01","lastUpdated":"2024-09-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"pixtral-large-latest","name":"Pixtral Large (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":128000,"maximumOutput":127999,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral's larger vision model for document-heavy image understanding and chat","family":"pixtral","knowledge":"2024-11","releaseDate":"2024-11-01","lastUpdated":"2024-11-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}},{"id":"voxtral-small-latest","name":"Voxtral Small (latest)","api":"mistral-conversations","baseUrl":null,"contextWindow":32000,"maximumOutput":31999,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Instruct model with native audio input for speech understanding and tool use","family":"voxtral","releaseDate":"2025-07-15","lastUpdated":"2025-07-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false}}]},{"id":"moonshotai","name":"Moonshot AI","endpoint":"https://api.moonshot.ai/v1","metadata":{"documentation":"https://platform.moonshot.ai/docs/api/chat","environmentVariables":"MOONSHOT_API_KEY"},"models":[{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-07-14","lastUpdated":"2025-07-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-09-05","lastUpdated":"2025-09-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Thinking Kimi model for slower research passes, planning, and hard technical questions","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.15,"output":8.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.4,"output":10.0,"cacheRead":0.6,"cacheWrite":0.0},"metadata":{"description":"Fast Kimi model for responsive chat, coding help, and agent loops","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-09-05","lastUpdated":"2025-09-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Earlier Kimi frontier model for long-context agents, coding, and multimodal work","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-01","lastUpdated":"2026-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi workhorse for agent loops, coding tasks, and visual context","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.19,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.9,"output":8.0,"cacheRead":0.38,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://api.moonshot.ai/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"moonshotai-cn","name":"Moonshot AI (China)","endpoint":"https://api.moonshot.cn/v1","metadata":{"documentation":"https://platform.moonshot.cn/docs/api/chat","environmentVariables":"MOONSHOT_API_KEY"},"models":[{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-07-14","lastUpdated":"2025-07-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-09-05","lastUpdated":"2025-09-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Thinking Kimi model for slower research passes, planning, and hard technical questions","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.15,"output":8.0,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Kimi reasoning model for long-horizon research, planning, and tool use","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.4,"output":10.0,"cacheRead":0.6,"cacheWrite":0.0},"metadata":{"description":"Fast Kimi model for responsive chat, coding help, and agent loops","family":"kimi-k2","knowledge":"2024-10","releaseDate":"2025-09-05","lastUpdated":"2025-09-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Earlier Kimi frontier model for long-context agents, coding, and multimodal work","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-01","lastUpdated":"2026-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi workhorse for agent loops, coding tasks, and visual context","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.19,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.9,"output":8.0,"cacheRead":0.38,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"deepseek","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"kimi-k3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://api.moonshot.cn/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"nvidia","name":"Nvidia","endpoint":"https://integrate.api.nvidia.com/v1","metadata":{"documentation":"https://docs.api.nvidia.com/nim/","environmentVariables":"NVIDIA_API_KEY"},"models":[{"id":"google/gemma-3-12b-it","name":"Gemma 3 12B IT","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2025-03-12","lastUpdated":"2025-03-12","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"google/gemma-3-4b-it","name":"Gemma 3 4B IT","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2025-03-12","lastUpdated":"2025-03-12","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta/llama-3.1-70b-instruct","name":"Llama 3.1 70b Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","releaseDate":"2024-07-16","lastUpdated":"2024-07-16","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta/llama-3.1-8b-instruct","name":"Llama 3.1 8B Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":16000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12","releaseDate":"2025-01-01","lastUpdated":"2025-01-01","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta/llama-3.2-11b-vision-instruct","name":"Llama 3.2 11b Vision Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama multimodal model for image understanding and text reasoning","knowledge":"2023-12","releaseDate":"2024-09-18","lastUpdated":"2024-09-18","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta/llama-3.2-90b-vision-instruct","name":"Llama-3.2-90B-Vision-Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama multimodal model for image understanding and text reasoning","family":"llama","knowledge":"2023-12","releaseDate":"2024-09-25","lastUpdated":"2024-09-25","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta/llama-3.3-70b-instruct","name":"Llama 3.3 70b Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","releaseDate":"2024-11-26","lastUpdated":"2024-11-26","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"minimaxai/minimax-m3","name":"MiniMax-M3","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":1000000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax multimodal model for long-context coding, perception, and agent planning","family":"minimax","releaseDate":"2026-06-01","lastUpdated":"2026-06-01","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/ministral-14b-instruct-2512","name":"Ministral 3 14B Instruct 2512","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral VLM for chat and instruction-based workloads","family":"ministral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mistral-7b-instruct-v0.3","name":"Mistral-7B-Instruct-v0.3","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":65536,"maximumOutput":65535,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","releaseDate":"2025-04-01","lastUpdated":"2025-04-01","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mistral-large-3-675b-instruct-2512","name":"Mistral Large 3 675B Instruct 2512","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral-large","knowledge":"2025-01","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mistral-medium-3.5-128b","name":"Mistral Medium 3.5","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Balanced Mistral model for enterprise assistants, multilingual work, and tools","family":"mistral-medium","releaseDate":"2026-04-29","lastUpdated":"2026-04-29","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mistral-small-4-119b-2603","name":"mistral-small-4-119b-2603","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":8192,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mixtral-8x22b-instruct","name":"Mistral: Mixtral 8x22B Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":65536,"maximumOutput":13108,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","releaseDate":"2024-04-17","lastUpdated":"2024-04-17","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"mistralai/mixtral-8x7b-instruct","name":"Mistral: Mixtral 8x7B Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":32768,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","releaseDate":"2023-12-10","lastUpdated":"2026-03-15","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/cosmos-reason2-8b","name":"Cosmos Reason2 8B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Vision language model for physical-world understanding with structured reasoning on video and images","releaseDate":"2025-12-01","lastUpdated":"2025-12-01","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.1-nemotron-70b-instruct","name":"Llama 3.1 Nemotron 70B Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron model for efficient reasoning, coding, and specialized AI agents","family":"nemotron","releaseDate":"2025-04-15","lastUpdated":"2025-04-15","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.1-nemotron-nano-8b-v1","name":"Llama 3.1 Nemotron Nano 8B v1","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron model for efficient reasoning, coding, and specialized AI agents","family":"nemotron","releaseDate":"2025-03-18","lastUpdated":"2025-03-18","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.1-nemotron-nano-vl-8b-v1","name":"Llama 3.1 Nemotron Nano VL 8B v1","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":32768,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron multimodal model for visual reasoning and agentic AI workflows","family":"nemotron","releaseDate":"2025-04-10","lastUpdated":"2025-04-10","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","name":"Llama 3.1 Nemotron Ultra 253B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship Nemotron model for high-throughput reasoning and complex agents","family":"nemotron","releaseDate":"2025-04-07","lastUpdated":"2025-04-07","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.3-nemotron-super-49b-v1","name":"Llama 3.3 Nemotron Super 49B v1","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron model for efficient reasoning, coding, and specialized AI agents","family":"nemotron","releaseDate":"2025-04-07","lastUpdated":"2025-04-07","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/llama-3.3-nemotron-super-49b-v1.5","name":"Llama 3.3 Nemotron Super 49B v1.5","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron model for efficient reasoning, coding, and specialized AI agents","family":"nemotron","releaseDate":"2025-07-25","lastUpdated":"2025-07-25","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-3-nano-30b-a3b","name":"nemotron-3-nano-30b-a3b","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Small Nemotron 3 MoE for efficient coding, math, and long-context agents","family":"nemotron","knowledge":"2024-09","releaseDate":"2024-12","lastUpdated":"2024-12","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning","name":"Nemotron 3 Nano Omni","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":256000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Nemotron omni model combining reasoning with text, vision, and audio","family":"nemotron","releaseDate":"2026-04-28","lastUpdated":"2026-04-28","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-3-super-120b-a12b","name":"Nemotron 3 Super","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":0.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","knowledge":"2024-04","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-3-ultra-550b-a55b","name":"Nemotron 3 Ultra 550B A55B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Largest Nemotron 3 model for maximum open-weight reasoning and agent accuracy","family":"nemotron","releaseDate":"2026-06-04","lastUpdated":"2026-06-04","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-nano-12b-v2-vl","name":"Nemotron Nano 12B v2 VL","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":127999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron multimodal model for visual reasoning and agentic AI workflows","family":"nemotron","releaseDate":"2025-10-28","lastUpdated":"2025-10-28","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-voicechat","name":"nemotron-voicechat","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron multimodal model for visual reasoning and agentic AI workflows","family":"nemotron","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nvidia-nemotron-nano-9b-v2","name":"nvidia-nemotron-nano-9b-v2","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Nemotron model for efficient reasoning and deployable AI agents","family":"nemotron","knowledge":"2024-09","releaseDate":"2025-08-18","lastUpdated":"2025-08-18","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-oss-120b","name":"GPT-OSS-120B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","knowledge":"2025-08","releaseDate":"2025-08-04","lastUpdated":"2025-08-14","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"poolside/laguna-xs-2.1","name":"Laguna XS 2.1","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Agentic coding model from Poolside in the XS size class for local deployment","family":"laguna","releaseDate":"2026-07-02","lastUpdated":"2026-07-02","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"qwen/qwen2.5-coder-32b-instruct","name":"Qwen2.5 Coder 32b Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","releaseDate":"2024-11-06","lastUpdated":"2024-11-06","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"qwen/qwen3-coder-480b-a35b-instruct","name":"Qwen3 Coder 480B A35B Instruct","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":66536,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-04","releaseDate":"2025-07-23","lastUpdated":"2025-07-23","openWeights":"false"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"qwen/qwen3.5-122b-a10b","name":"Qwen3.5 122B-A10B","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"stepfun-ai/step-3.5-flash","name":"Step 3.5 Flash","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":256000,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"StepFun flash model for efficient multimodal reasoning, coding, and tool use","releaseDate":"2026-02-02","lastUpdated":"2026-02-02","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"stepfun-ai/step-3.7-flash","name":"Step 3.7 Flash","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":256000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["minimal","low","medium","high","xhigh","max"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"StepFun flash model for efficient multimodal reasoning, coding, and tool use","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"thinkingmachines/inkling","name":"Inkling","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":1048576,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal MoE reasoning model (975B total, 41B active) for text, image, and audio","family":"ling","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"z-ai/glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://integrate.api.nvidia.com/v1","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{"NVCF-POLL-SECONDS":"3600"},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}}]},{"id":"openai","name":"OpenAI","endpoint":null,"metadata":{"documentation":"https://platform.openai.com/docs/models","environmentVariables":"OPENAI_API_KEY"},"models":[{"id":"gpt-4.1","name":"GPT-4.1","api":"openai-responses","baseUrl":null,"contextWindow":1047576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":8.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"Long-lived GPT workhorse for coding, instruction following, and production apps","family":"gpt","knowledge":"2024-04","releaseDate":"2025-04-14","lastUpdated":"2025-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-4.1-mini","name":"GPT-4.1 mini","api":"openai-responses","baseUrl":null,"contextWindow":1047576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":1.6,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Affordable GPT-4.1 lane for fast coding help and structured extraction","family":"gpt-mini","knowledge":"2024-04","releaseDate":"2025-04-14","lastUpdated":"2025-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-4o","name":"GPT-4o","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"Omni-era GPT for multimodal chat, practical coding, and general assistants","family":"gpt","knowledge":"2023-09","releaseDate":"2024-05-13","lastUpdated":"2024-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-4o-2024-08-06","name":"GPT-4o (2024-08-06)","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-08-06","lastUpdated":"2024-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-4o-2024-11-20","name":"GPT-4o (2024-11-20)","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-11-20","lastUpdated":"2024-11-20","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-4o-mini","name":"GPT-4o mini","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Small omni GPT for cheap multimodal assistance and production-scale traffic","family":"gpt-mini","knowledge":"2023-09","releaseDate":"2024-07-18","lastUpdated":"2024-07-18","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5","name":"GPT-5","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Original GPT-5 workhorse for reasoning, coding, writing, and tool workflows","family":"gpt","knowledge":"2024-09-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5-mini","name":"GPT-5 Mini","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":2.0,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Small GPT-5 for responsive agents, coding help, and everyday automation","family":"gpt-mini","knowledge":"2024-05-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5-nano","name":"GPT-5 Nano","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.05,"output":0.4,"cacheRead":0.005,"cacheWrite":0.0},"metadata":{"description":"Tiny GPT-5 lane for routing, extraction, classification, and bulk jobs","family":"gpt-nano","knowledge":"2024-05-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5-pro","name":"GPT-5 Pro","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":272000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{"high":"high"},"cost":{"input":15.0,"output":120.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Higher-accuracy GPT-5 tier for tough analysis, coding reviews, and planning","family":"gpt-pro","knowledge":"2024-09-30","releaseDate":"2025-10-06","lastUpdated":"2025-10-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.1","name":"GPT-5.1","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Sharper GPT-5 generation for coding, product work, and tool-assisted tasks","family":"gpt","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.2","name":"GPT-5.2","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Reliable GPT generation for broad coding, writing, and tool-assisted product work","family":"gpt","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.2-chat-latest","name":"GPT-5.2 Chat","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","xhigh"],"reasoningValues":{"medium":"medium","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Chat-tuned GPT model for conversational assistance, writing, and tool workflows","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.2-pro","name":"GPT-5.2 Pro","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":21.0,"output":168.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Higher-accuracy GPT-5.2 variant for tougher reasoning and review workflows","family":"gpt-pro","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.3-chat-latest","name":"GPT-5.3 Chat (latest)","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Chat-tuned GPT model for conversational assistance, writing, and tool workflows","family":"gpt","knowledge":"2025-08-31","releaseDate":"2026-03-03","lastUpdated":"2026-03-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.3-codex-spark","name":"GPT-5.3 Codex Spark","api":"openai-responses","baseUrl":null,"contextWindow":128000,"maximumOutput":32000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex-spark","knowledge":"2025-08-31","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.4","name":"GPT-5.4","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0,"tiers":[{"above":272000,"input":5.0,"output":22.5,"cacheRead":0.5,"cacheWrite":0.0}]},"metadata":{"description":"Agent-ready GPT for coding and computer-use workflows at a lower cost","family":"gpt","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-03-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.4-mini","name":"GPT-5.4 mini","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Strong small GPT for coding subagents, quick tool use, and high-volume work","family":"gpt-mini","knowledge":"2025-08-31","releaseDate":"2026-03-17","lastUpdated":"2026-03-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.4-nano","name":"GPT-5.4 nano","api":"openai-responses","baseUrl":null,"contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.2,"output":1.25,"cacheRead":0.02,"cacheWrite":0.0},"metadata":{"description":"Cheapest GPT-5.4 lane for simple routing, extraction, and bulk automation","family":"gpt-nano","knowledge":"2025-08-31","releaseDate":"2026-03-17","lastUpdated":"2026-03-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":30.0,"output":180.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":272000,"input":60.0,"output":270.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"More exact GPT-5.4 tier for demanding professional reasoning and agent tasks","family":"gpt-pro","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-03-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":false,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.5","name":"GPT-5.5","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":0.0}]},"metadata":{"description":"Default frontier GPT for coding, computer use, research, and knowledge work","family":"gpt","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.5-pro","name":"GPT-5.5 Pro","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":30.0,"output":180.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":272000,"input":60.0,"output":270.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Highest-accuracy GPT-5.5 tier for slower, precision-heavy reasoning and coding","family":"gpt-pro","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.6","name":"GPT-5.6","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":true,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.2,"output":1.2,"cacheRead":0.02,"cacheWrite":0.25,"tiers":[{"above":272000,"input":0.4,"output":1.8,"cacheRead":0.04,"cacheWrite":0.5}]},"metadata":{"description":"Cost-efficient GPT-5.6 model for fast, high-volume workloads","family":"gpt-luna","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":true,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":true,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"gpt-5.6-terra","name":"GPT-5.6 Terra","api":"openai-responses","baseUrl":null,"contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":2.5,"tiers":[{"above":272000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":5.0}]},"metadata":{"description":"Balanced GPT-5.6 model for capable, cost-efficient everyday work","family":"gpt-terra","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":true,"supportsAdditionalTools":true,"supportsToolSearch":true,"supportsExplicitPromptCacheMode":true,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"o3","name":"o3","api":"openai-responses","baseUrl":null,"contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":8.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"Deliberate o-series reasoner for hard math, coding, and multi-step analysis","family":"o","knowledge":"2024-05","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}},{"id":"o3-pro","name":"o3-pro","api":"openai-responses","baseUrl":null,"contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":20.0,"output":80.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"High-effort o3 tier for difficult technical reasoning and careful answers","family":"o-pro","knowledge":"2024-05","releaseDate":"2025-06-10","lastUpdated":"2025-06-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":true,"sessionAffinityFormat":"openai"}}]},{"id":"openrouter","name":"OpenRouter","endpoint":"https://openrouter.ai/api/v1","metadata":{"documentation":"https://openrouter.ai/models","environmentVariables":"OPENROUTER_API_KEY"},"models":[{"id":"~anthropic/claude-fable-latest","name":"Claude Fable Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"~anthropic/claude-haiku-latest","name":"Anthropic Claude Haiku Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude-haiku","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"~anthropic/claude-opus-latest","name":"Claude Opus Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"~anthropic/claude-sonnet-latest","name":"Anthropic Claude Sonnet Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"~deepseek/deepseek-v4-flash-latest","name":"DeepSeek V4 Flash Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":0.09,"output":0.18,"cacheRead":0.018,"cacheWrite":0.0},"metadata":{"description":"Fast DeepSeek model for efficient chat, coding help, and agent loops","family":"deepseek","releaseDate":"2026-08-01","lastUpdated":"2026-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~google/gemini-flash-latest","name":"Google Gemini Flash Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.15,"cacheWrite":0.083333},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01-01","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~google/gemini-pro-latest","name":"Google Gemini Pro Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.375,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~moonshotai/kimi-latest","name":"MoonshotAI Kimi Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":2.5,"output":14.0,"cacheRead":0.29,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~openai/gpt-latest","name":"OpenAI GPT Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2026-02-16","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~openai/gpt-mini-latest","name":"OpenAI GPT Mini Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt-mini","knowledge":"2025-08-31","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"~x-ai/grok-latest","name":"Grok Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":500000,"maximumOutput":499999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":6.0,"cacheRead":0.3,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":12.0,"cacheRead":0.6,"cacheWrite":0.0}]},"metadata":{"description":"Grok model for agentic tool use, reasoning, coding, and live assistance","family":"grok","releaseDate":"2026-07-08","lastUpdated":"2026-07-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"ai21/jamba-large-1.7","name":"Jamba Large 1.7","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":4096,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":8.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"jamba","knowledge":"2024-08-31","releaseDate":"2025-08-08","lastUpdated":"2025-08-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"aion-labs/aion-2.0","name":"Aion-2.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.8,"output":1.6,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"aion-labs/aion-3.0","name":"Aion-3.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":6.0,"cacheRead":0.75,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","releaseDate":"2026-07-07","lastUpdated":"2026-07-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"aion-labs/aion-3.0-mini","name":"Aion-3.0-Mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.7,"output":1.4,"cacheRead":0.18,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","releaseDate":"2026-07-07","lastUpdated":"2026-07-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"amazon/nova-2-lite-v1","name":"Nova 2 Lite","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65535,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":2.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"nova","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"amazon/nova-lite-v1","name":"Nova Lite 1.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":300000,"maximumOutput":5120,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.06,"output":0.24,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"nova-lite","knowledge":"2024-10-31","releaseDate":"2024-12-05","lastUpdated":"2024-12-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"amazon/nova-micro-v1","name":"Nova Micro 1.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":5120,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.035,"output":0.14,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"nova-micro","knowledge":"2024-10-31","releaseDate":"2024-12-05","lastUpdated":"2024-12-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"amazon/nova-premier-v1","name":"Nova Premier 1.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":32000,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":12.5,"cacheRead":0.625,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"nova","releaseDate":"2025-10-31","lastUpdated":"2025-10-31","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"amazon/nova-pro-v1","name":"Nova Pro 1.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":300000,"maximumOutput":5120,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.8,"output":3.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"nova-pro","knowledge":"2024-10-31","releaseDate":"2024-12-05","lastUpdated":"2024-12-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"anthropic/claude-3-haiku","name":"Claude 3 Haiku","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.25,"output":1.25,"cacheRead":0.03,"cacheWrite":0.3},"metadata":{"description":"Fast Claude model for responsive assistance, classification, and lightweight agents","family":"claude","knowledge":"2023-08-31","releaseDate":"2024-03-13","lastUpdated":"2024-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-fable-5","name":"Claude Fable 5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","knowledge":"2026-01-31","releaseDate":"2026-06-09","lastUpdated":"2026-06-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-haiku-4.5","name":"Claude Haiku 4.5 (latest)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":5.0,"cacheRead":0.1,"cacheWrite":1.25},"metadata":{"description":"Fast Claude lane for lightweight agents, office tasks, and responsive chat","family":"claude-haiku","knowledge":"2025-02-28","releaseDate":"2025-10-15","lastUpdated":"2025-10-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4","name":"Claude Opus 4","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":32000,"input":["image","text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":15.0,"output":75.0,"cacheRead":1.5,"cacheWrite":18.75},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-01-31","releaseDate":"2025-05-22","lastUpdated":"2025-05-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.1","name":"Claude Opus 4.1 (latest)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":32000,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":15.0,"output":75.0,"cacheRead":1.5,"cacheWrite":18.75},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-03-31","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.5","name":"Claude Opus 4.5 (latest)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2025-05","releaseDate":"2025-11-24","lastUpdated":"2025-11-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.6","name":"Claude Opus 4.6","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":200000,"input":10.0,"output":37.5,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"High-end Claude for difficult coding, planning, and slower expert reasoning","family":"claude-opus","knowledge":"2025-05-31","releaseDate":"2026-02-05","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.7","name":"Claude Opus 4.7","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":200000,"input":10.0,"output":37.5,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"Stronger Opus tier for advanced software work and high-stakes reasoning","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.7-fast","name":"Claude Opus 4.7 (Fast)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":30.0,"output":150.0,"cacheRead":3.0,"cacheWrite":37.5},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01-31","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.8","name":"Claude Opus 4.8","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Top Claude Opus tier for the hardest reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-4.8-fast","name":"Claude Opus 4.8 (Fast)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-5","name":"Claude Opus 5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":25.0,"cacheRead":0.5,"cacheWrite":6.25},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-opus-5-fast","name":"Claude Opus 5 (Fast)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":10.0,"output":50.0,"cacheRead":1.0,"cacheWrite":12.5},"metadata":{"description":"Flagship Claude model for deep reasoning, coding, and long-horizon agents","family":"claude-opus","knowledge":"2026-05","releaseDate":"2026-07-24","lastUpdated":"2026-07-24","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-sonnet-4","name":"Claude Sonnet 4","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":64000,"input":["image","text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75,"tiers":[{"above":200000,"input":6.0,"output":22.5,"cacheRead":0.6,"cacheWrite":7.5}]},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-01-31","releaseDate":"2025-05-22","lastUpdated":"2025-05-22","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-sonnet-4.5","name":"Claude Sonnet 4.5 (latest)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":64000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75,"tiers":[{"above":200000,"input":6.0,"output":22.5,"cacheRead":0.6,"cacheWrite":7.5}]},"metadata":{"description":"Balanced Claude model for coding, analysis, agent workflows, and cost control","family":"claude-sonnet","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-sonnet-4.6","name":"Claude Sonnet 4.6","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":3.75,"tiers":[{"above":200000,"input":6.0,"output":22.5,"cacheRead":0.6,"cacheWrite":7.5}]},"metadata":{"description":"Claude workhorse for coding agents, careful analysis, and production cost control","family":"claude-sonnet","knowledge":"2025-08-31","releaseDate":"2026-02-17","lastUpdated":"2026-03-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"anthropic/claude-sonnet-5","name":"Claude Sonnet 5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh","max"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":2.0,"output":10.0,"cacheRead":0.2,"cacheWrite":2.5},"metadata":{"description":"Everyday Claude agent model for coding, planning, browsing, and general work","family":"claude-sonnet","knowledge":"2026-01-31","releaseDate":"2026-06-30","lastUpdated":"2026-06-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true,"cacheControlFormat":"anthropic"}},{"id":"arcee-ai/trinity-large-thinking","name":"Trinity Large Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.22,"output":0.85,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"trinity","releaseDate":"2026-04-01","lastUpdated":"2026-04-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"arcee-ai/virtuoso-large","name":"Virtuoso Large","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":64000,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.75,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","knowledge":"2025-03-31","releaseDate":"2025-05-05","lastUpdated":"2025-05-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"bytedance-seed/seed-1.6","name":"Seed 1.6","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.25,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":128000,"input":0.5,"output":4.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"seed","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"bytedance-seed/seed-1.6-flash","name":"Seed 1.6 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.075,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":128000,"input":0.1,"output":0.8,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"seed","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"bytedance-seed/seed-2.0-lite","name":"Seed-2.0-Lite","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":128000,"input":0.5,"output":4.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"seed","releaseDate":"2026-03-10","lastUpdated":"2026-03-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"bytedance-seed/seed-2.0-mini","name":"Seed-2.0-Mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.1,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":128000,"input":0.2,"output":0.8,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"seed","releaseDate":"2026-02-26","lastUpdated":"2026-02-26","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"cohere/command-r-08-2024","name":"Command R","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":4000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Cohere retrieval model for long-context chat and enterprise RAG workflows","family":"command-r","knowledge":"2024-06-01","releaseDate":"2024-08-30","lastUpdated":"2024-08-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"cohere/command-r-plus-08-2024","name":"Command R+","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":4000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Cohere's RAG workhorse for long-context enterprise search and tool use","family":"command-r","knowledge":"2024-06-01","releaseDate":"2024-08-30","lastUpdated":"2024-08-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"cohere/north-mini-code:free","name":"North Mini Code (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":64000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Cohere coding model for practical software engineering and agentic edits","family":"north","releaseDate":"2026-06-17","lastUpdated":"2026-06-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-chat","name":"DeepSeek Chat","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":16000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2574,"output":1.0287,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2025-09","releaseDate":"2025-12-01","lastUpdated":"2026-02-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-chat-v3-0324","name":"DeepSeek V3 0324","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.27,"output":1.12,"cacheRead":0.135,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2024-07-31","releaseDate":"2025-03-24","lastUpdated":"2025-03-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-chat-v3.1","name":"DeepSeek V3.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.25,"output":0.95,"cacheRead":0.13,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2025-03-31","releaseDate":"2025-08-21","lastUpdated":"2025-08-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-r1","name":"DeepSeek-R1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":16000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.7,"output":2.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Classic open reasoning model for transparent math, coding, and deliberate problem solving","family":"deepseek-thinking","knowledge":"2024-07","releaseDate":"2025-01-20","lastUpdated":"2025-05-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-r1-0528","name":"R1 0528","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":2.15,"cacheRead":0.35,"cacheWrite":0.0},"metadata":{"description":"DeepSeek reasoning model for multi-step analysis, math, coding, and tools","family":"deepseek","knowledge":"2025-03-31","releaseDate":"2025-05-28","lastUpdated":"2025-05-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v3.1-terminus","name":"DeepSeek V3.1 Terminus","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.27,"output":1.0,"cacheRead":0.135,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2025-03-31","releaseDate":"2025-09-22","lastUpdated":"2025-09-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v3.2","name":"DeepSeek V3.2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":163839,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.26,"output":0.38,"cacheRead":0.13,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2024-07","releaseDate":"2025-12-01","lastUpdated":"2025-12-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v3.2-exp","name":"DeepSeek V3.2 Exp","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":163840,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.27,"output":0.41,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"DeepSeek chat model for instruction following, coding, and analysis","family":"deepseek","knowledge":"2025-07-31","releaseDate":"2025-09-29","lastUpdated":"2025-09-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v4-flash","name":"DeepSeek V4 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":393216,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","xhigh"],"reasoningValues":{"high":"high","xhigh":"xhigh"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.028,"cacheWrite":0.0},"metadata":{"description":"Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v4-flash-0731","name":"DeepSeek V4 Flash 0731","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":0.09,"output":0.18,"cacheRead":0.018,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"deepseek/deepseek-v4-pro","name":"DeepSeek V4 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","xhigh"],"reasoningValues":{"high":"high","xhigh":"xhigh"},"cost":{"input":0.435,"output":0.87,"cacheRead":0.003625,"cacheWrite":0.0},"metadata":{"description":"Open MoE flagship with million-token context for coding and long agent runs","family":"deepseek-thinking","knowledge":"2025-05","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-2.5-flash","name":"Gemini 2.5 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65535,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.083333},"metadata":{"description":"Fast Gemini workhorse for multimodal apps where latency and price matter","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-2.5-flash-lite","name":"Gemini 2.5 Flash-Lite","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65535,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.4,"cacheRead":0.01,"cacheWrite":0.083333},"metadata":{"description":"Lean Gemini 2.5 lane for cheap multimodal traffic and quick agents","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-2.5-pro","name":"Gemini 2.5 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.375,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0}]},"metadata":{"description":"Google's proven reasoning model for coding, math, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-2.5-pro-preview","name":"Gemini 2.5 Pro Preview 06-05","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.375,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini","knowledge":"2025-01-31","releaseDate":"2025-06-05","lastUpdated":"2025-06-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-2.5-pro-preview-05-06","name":"Gemini 2.5 Pro Preview 05-06","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65535,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.375,"tiers":[{"above":200000,"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01-31","releaseDate":"2025-05-07","lastUpdated":"2025-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3-flash-preview","name":"Gemini 3 Flash Preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.5,"output":3.0,"cacheRead":0.05,"cacheWrite":0.083333},"metadata":{"description":"New Gemini flash lane bringing frontier-style multimodal reasoning to cheaper runs","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2025-12-17","lastUpdated":"2025-12-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3-pro-image","name":"Nano Banana Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.375},"metadata":{"description":"Nano Banana Pro for higher-fidelity image generation and design-heavy edits","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-05-28","lastUpdated":"2026-05-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.1-flash-lite","name":"Gemini 3.1 Flash Lite","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.083333},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-05-07","lastUpdated":"2026-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.1-flash-lite-preview","name":"Gemini 3.1 Flash Lite Preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":1.5,"cacheRead":0.025,"cacheWrite":0.083333},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2025-01","releaseDate":"2026-03-03","lastUpdated":"2026-03-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.375,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Reasoning-first Gemini preview for agentic coding and complex problem solving","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.1-pro-preview-customtools","name":"Gemini 3.1 Pro Preview Custom Tools","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":12.0,"cacheRead":0.2,"cacheWrite":0.375,"tiers":[{"above":200000,"input":4.0,"output":18.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Advanced Gemini model for complex reasoning, coding, and multimodal analysis","family":"gemini-pro","knowledge":"2025-01","releaseDate":"2026-02-19","lastUpdated":"2026-02-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.5-flash","name":"Gemini 3.5 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":9.0,"cacheRead":0.15,"cacheWrite":0.083333},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2025-01","releaseDate":"2026-05-19","lastUpdated":"2026-05-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.5-flash-lite","name":"Gemini 3.5 Flash Lite","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.083333},"metadata":{"description":"Low-latency Gemini model for high-volume multimodal and agent workloads","family":"gemini-flash-lite","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemini-3.6-flash","name":"Gemini 3.6 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.15,"cacheWrite":0.083333},"metadata":{"description":"Fast Gemini model balancing multimodal reasoning, tool use, and cost","family":"gemini-flash","knowledge":"2026-03","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-3-12b-it","name":"Gemma 3 12B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.05,"output":0.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","knowledge":"2024-08-31","releaseDate":"2025-03-13","lastUpdated":"2025-03-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-3-27b-it","name":"Gemma 3 27B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.08,"output":0.45,"cacheRead":0.04,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","knowledge":"2024-08-31","releaseDate":"2025-03-12","lastUpdated":"2025-03-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-4-26b-a4b-it","name":"Gemma 4 26B A4B IT","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":16384,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.07,"output":0.34,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-4-26b-a4b-it:free","name":"Gemma 4 26B A4B (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-4-31b-it","name":"Gemma 4 31B IT","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.34,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Largest Gemma 4 instruction model for open, self-hosted chat and reasoning","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"google/gemma-4-31b-it:free","name":"Gemma 4 31B (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Largest Gemma 4 instruction model for open, self-hosted chat and reasoning","family":"gemma","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"ibm-granite/granite-4.1-8b","name":"Granite 4.1 8B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.05,"output":0.1,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"Open-weight instruction model for adaptable chat and self-hosted production workloads","family":"granite","releaseDate":"2026-04-30","lastUpdated":"2026-04-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inception/mercury-2","name":"Mercury 2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":50000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":0.75,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"mercury","releaseDate":"2026-03-04","lastUpdated":"2026-03-04","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inclusionai/ling-2.6-1t","name":"Ling-2.6-1T","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.075,"output":0.625,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Tool-capable chat model for instruction following and agentic application workflows","family":"ling","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inclusionai/ling-2.6-flash","name":"Ling-2.6-flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.01,"output":0.03,"cacheRead":0.002,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"ling","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inclusionai/ling-3.0-flash","name":"Ling-3.0-flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.021,"output":0.063,"cacheRead":0.0042,"cacheWrite":0.0},"metadata":{"description":"Efficient model for low-latency assistance, extraction, and routine automation","family":"ling","releaseDate":"2026-07-23","lastUpdated":"2026-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inclusionai/ling-3.0-tiny:free","name":"Ling 3.0 Tiny (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Free provider route for experiments, demos, and cost-sensitive chat workloads","family":"ling","releaseDate":"2026-08-06","lastUpdated":"2026-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"inclusionai/ring-2.6-1t","name":"Ring-2.6-1T","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["high","xhigh"],"reasoningValues":{"high":"high","xhigh":"xhigh"},"cost":{"input":0.075,"output":0.625,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"ring","releaseDate":"2026-05-08","lastUpdated":"2026-05-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"kwaipilot/kat-coder-air-v2.5","name":"KAT-Coder-Air V2.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":80000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Coding model for repository understanding, refactors, and agentic engineering tasks","family":"kat-coder","releaseDate":"2026-07-10","lastUpdated":"2026-07-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"kwaipilot/kat-coder-pro-v2","name":"KAT-Coder-Pro V2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":80000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"Coding model for repository understanding, refactors, and agentic engineering tasks","family":"kat-coder","releaseDate":"2026-03-27","lastUpdated":"2026-03-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"kwaipilot/kat-coder-pro-v2.5","name":"KAT-Coder-Pro V2.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":80000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.74,"output":2.96,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Coding model for repository understanding, refactors, and agentic engineering tasks","family":"kat-coder","releaseDate":"2026-07-10","lastUpdated":"2026-07-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meituan/longcat-2.0","name":"LongCat 2.0","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048756,"maximumOutput":262144,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.006,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"longcat","releaseDate":"2026-07-20","lastUpdated":"2026-07-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta-llama/llama-3.1-70b-instruct","name":"Llama 3.1 70B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12-31","releaseDate":"2024-07-23","lastUpdated":"2024-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta-llama/llama-3.1-8b-instruct","name":"Llama 3.1 8B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.05,"output":0.08,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12-31","releaseDate":"2024-07-23","lastUpdated":"2024-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta-llama/llama-3.3-70b-instruct","name":"Llama-3.3-70B-Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.32,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Popular open Llama workhorse for multilingual chat, coding, and self-hosting","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2024-12-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta-llama/llama-4-maverick","name":"Llama 4 Maverick","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":0.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","knowledge":"2024-08-31","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta-llama/llama-4-scout","name":"Llama 4 Scout","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1310720,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","knowledge":"2024-08-31","releaseDate":"2025-04-05","lastUpdated":"2025-04-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta/muse-spark-1.1","name":"Muse Spark 1.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high","xhigh"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.25,"output":4.25,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Open Llama multimodal model for image understanding and text reasoning","family":"muse","releaseDate":"2026-04-08","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"meta/muse-spark-1.2","name":"Muse Spark 1.2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high","xhigh"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.25,"output":4.25,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Muse Spark 1.2 is a coding-focused update to Muse Spark 1.1 with improvements in code generation, complex debugging, codebase understanding, and end-to-end developer workflows.","family":"muse","releaseDate":"2026-08-05","lastUpdated":"2026-08-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m1","name":"MiniMax M1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":40000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.55,"output":2.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","knowledge":"2024-06-30","releaseDate":"2025-06-17","lastUpdated":"2025-06-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m2","name":"MiniMax-M2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.255,"output":1.02,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient open MiniMax model built for coding agents and tool-heavy workflows","family":"minimax","releaseDate":"2025-10-27","lastUpdated":"2025-10-27","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m2.1","name":"MiniMax-M2.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Earlier MiniMax agent model for practical coding and productivity tasks","family":"minimax","releaseDate":"2025-12-23","lastUpdated":"2025-12-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m2.5","name":"MiniMax-M2.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":196608,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.22,"output":0.9,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"Prior MiniMax coding model for agent workflows, office edits, and automation","family":"minimax","releaseDate":"2026-02-12","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m2.7","name":"MiniMax-M2.7","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.27,"output":1.08,"cacheRead":0.054,"cacheWrite":0.0},"metadata":{"description":"Open MiniMax flagship for coding agents, office automation, and complex environments","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"minimax/minimax-m3","name":"MiniMax-M3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":512000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax multimodal model for long-context coding, perception, and agent planning","family":"minimax","releaseDate":"2026-06-01","lastUpdated":"2026-06-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/codestral-2508","name":"Codestral 2508","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":255999,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Mistral coding model for code completion, generation, and developer workflows","family":"codestral","knowledge":"2025-03-31","releaseDate":"2025-08-01","lastUpdated":"2025-08-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/ministral-14b-2512","name":"Ministral 3 14B 2512","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":0.2,"cacheRead":0.02,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/ministral-3b-2512","name":"Ministral 3 3B 2512","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.1,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/ministral-8b-2512","name":"Ministral 3 8B 2512","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.15,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads","family":"ministral","releaseDate":"2025-12-02","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-large","name":"Mistral Large","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral-large","knowledge":"2024-11-30","releaseDate":"2024-02-26","lastUpdated":"2024-02-26","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-large-2407","name":"Mistral Large 2407","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship Mistral model for advanced reasoning, coding, and multilingual work","family":"mistral-large","knowledge":"2024-03-31","releaseDate":"2024-11-19","lastUpdated":"2024-11-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-large-2512","name":"Mistral Large 3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"Mistral's largest general model for enterprise agents, coding, and multilingual reasoning","family":"mistral-large","knowledge":"2024-11","releaseDate":"2024-11-01","lastUpdated":"2025-12-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-medium-3","name":"Mistral Medium 3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":2.0,"cacheRead":0.04,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral-medium","knowledge":"2025-03-31","releaseDate":"2025-05-07","lastUpdated":"2025-05-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-medium-3-5","name":"Mistral Medium 3.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":1.5,"output":7.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral-medium","releaseDate":"2026-04-30","lastUpdated":"2026-04-30","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-medium-3.1","name":"Mistral Medium 3.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":2.0,"cacheRead":0.04,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral-medium","knowledge":"2025-06-30","releaseDate":"2025-08-13","lastUpdated":"2025-08-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-nemo","name":"Mistral Nemo","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.019,"output":0.03,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral-NVIDIA open model for multilingual chat and local deployment","family":"mistral-nemo","knowledge":"2024-07","releaseDate":"2024-07-01","lastUpdated":"2024-07-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-saba","name":"Saba","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":32768,"maximumOutput":32767,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.2,"output":0.6,"cacheRead":0.02,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral","knowledge":"2024-09-30","releaseDate":"2025-02-17","lastUpdated":"2025-02-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-small-2603","name":"Mistral Small 4","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{"off":"none","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Fast Mistral production model for chat, extraction, and cost-sensitive agents","family":"mistral-small","knowledge":"2025-06","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mistral-small-3.2-24b-instruct","name":"Mistral Small 3.2 24B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":16384,"input":["image","text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.09375,"output":0.25,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral-small","knowledge":"2023-10-31","releaseDate":"2025-06-20","lastUpdated":"2025-06-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/mixtral-8x22b-instruct","name":"Mixtral 8x22B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":65536,"maximumOutput":65535,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":6.0,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Mistral model for multilingual chat, reasoning, and tool-assisted workflows","family":"mistral","knowledge":"2024-01-31","releaseDate":"2024-04-17","lastUpdated":"2024-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"mistralai/voxtral-small-24b-2507","name":"Voxtral Small 24B 2507","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":32000,"maximumOutput":31999,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Efficient Mistral model for fast chat, extraction, and production assistants","family":"mistral","releaseDate":"2025-10-30","lastUpdated":"2025-10-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2","name":"Kimi K2 0711","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":100352,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.57,"output":2.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-12-31","releaseDate":"2025-07-11","lastUpdated":"2025-07-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2-0905","name":"Kimi K2 0905","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":100352,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Kimi model for long-context chat, coding, and agentic reasoning","family":"kimi-k2","knowledge":"2024-12-31","releaseDate":"2025-09-04","lastUpdated":"2025-09-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2-thinking","name":"Kimi K2 Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":100352,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Thinking Kimi model for slower research passes, planning, and hard technical questions","family":"kimi-thinking","knowledge":"2024-08","releaseDate":"2025-11-06","lastUpdated":"2025-11-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.57,"output":2.85,"cacheRead":0.095,"cacheWrite":0.0},"metadata":{"description":"Earlier Kimi frontier model for long-context agents, coding, and multimodal work","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-01","lastUpdated":"2026-01","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5795,"output":2.44,"cacheRead":0.0976,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi workhorse for agent loops, coding tasks, and visual context","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.7,"output":3.5,"cacheRead":0.15,"cacheWrite":0.0},"metadata":{"description":"Coding-focused Kimi model, stronger on long-horizon repo work with less overthinking","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"moonshotai/kimi-k3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":1048575,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","high","max"],"reasoningValues":{"low":"low","high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nex-agi/nex-n2-mini","name":"Nex-N2-Mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.025,"output":0.1,"cacheRead":0.0025,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"agi","releaseDate":"2026-06-24","lastUpdated":"2026-06-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nex-agi/nex-n2-pro","name":"Nex-N2-Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.25,"output":1.0,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"agi","releaseDate":"2026-06-08","lastUpdated":"2026-06-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-nano-30b-a3b","name":"Nemotron 3 Nano 30B A3B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.05,"output":0.2,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Small Nemotron 3 MoE for efficient coding, math, and long-context agents","family":"nemotron","releaseDate":"2025-12-15","lastUpdated":"2025-12-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-nano-30b-a3b:free","name":"Nemotron 3 Nano 30B A3B (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":255999,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Small Nemotron 3 MoE for efficient coding, math, and long-context agents","family":"nemotron","releaseDate":"2025-12-15","lastUpdated":"2025-12-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free","name":"Nemotron 3 Nano Omni (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Nemotron omni model combining reasoning with text, vision, and audio","family":"nemotron","releaseDate":"2026-04-28","lastUpdated":"2026-04-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-super-120b-a12b","name":"Nemotron 3 Super 120B A12B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium"],"reasoningValues":{"low":"low","medium":"medium"},"cost":{"input":0.085,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","name":"Nemotron 3 Super (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium"],"reasoningValues":{"low":"low","medium":"medium"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron middle tier for collaborative agents and high-volume reasoning workloads","family":"nemotron","releaseDate":"2026-03-11","lastUpdated":"2026-03-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-ultra-550b-a55b","name":"Nemotron 3 Ultra 550B A55B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":512288,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","high"],"reasoningValues":{"medium":"medium","high":"high"},"cost":{"input":0.6,"output":3.6,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Largest Nemotron 3 model for maximum open-weight reasoning and agent accuracy","family":"nemotron","releaseDate":"2026-06-04","lastUpdated":"2026-06-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-3-ultra-550b-a55b:free","name":"Nemotron 3 Ultra (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["medium","high"],"reasoningValues":{"medium":"medium","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Largest Nemotron 3 model for maximum open-weight reasoning and agent accuracy","family":"nemotron","releaseDate":"2026-06-04","lastUpdated":"2026-06-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-nano-12b-v2-vl:free","name":"Nemotron Nano 12B 2 VL (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":127999,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Nemotron multimodal model for visual reasoning and agentic AI workflows","family":"nemotron","releaseDate":"2025-10-28","lastUpdated":"2025-10-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"nvidia/nemotron-nano-9b-v2:free","name":"Nemotron Nano 9B V2 (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":127999,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Nemotron model for efficient reasoning and deployable AI agents","family":"nemotron","releaseDate":"2025-08-18","lastUpdated":"2025-08-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-3.5-turbo","name":"GPT-3.5-turbo","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":16385,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.5,"output":1.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2021-09-01","releaseDate":"2023-03-01","lastUpdated":"2023-11-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-3.5-turbo-0613","name":"GPT-3.5 Turbo (older v0613)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":4095,"maximumOutput":4094,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.0,"output":2.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2021-09-30","releaseDate":"2024-01-25","lastUpdated":"2024-01-25","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-3.5-turbo-16k","name":"GPT-3.5 Turbo 16k","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":16385,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":3.0,"output":4.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2021-09-30","releaseDate":"2023-08-28","lastUpdated":"2023-08-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4","name":"GPT-4","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":8191,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":30.0,"output":60.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-11","releaseDate":"2023-11-06","lastUpdated":"2024-04-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4-turbo","name":"GPT-4 Turbo","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":10.0,"output":30.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2023-12","releaseDate":"2023-11-06","lastUpdated":"2024-04-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4-turbo-preview","name":"GPT-4 Turbo Preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":10.0,"output":30.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"gpt","knowledge":"2023-12-31","releaseDate":"2024-01-25","lastUpdated":"2024-01-25","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4.1","name":"GPT-4.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1047576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.0,"output":8.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"Long-lived GPT workhorse for coding, instruction following, and production apps","family":"gpt","knowledge":"2024-04","releaseDate":"2025-04-14","lastUpdated":"2025-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4.1-mini","name":"GPT-4.1 mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1047576,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":1.6,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Affordable GPT-4.1 lane for fast coding help and structured extraction","family":"gpt-mini","knowledge":"2024-04","releaseDate":"2025-04-14","lastUpdated":"2025-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4.1-nano","name":"GPT-4.1 nano","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1047576,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.4,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Tiny GPT-4.1 option for classification, routing, and very high-volume tasks","family":"gpt-nano","knowledge":"2024-04","releaseDate":"2025-04-14","lastUpdated":"2025-04-14","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o","name":"GPT-4o","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"Omni-era GPT for multimodal chat, practical coding, and general assistants","family":"gpt","knowledge":"2023-09","releaseDate":"2024-05-13","lastUpdated":"2024-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o-2024-05-13","name":"GPT-4o (2024-05-13)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":4096,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":5.0,"output":15.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-05-13","lastUpdated":"2024-05-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o-2024-08-06","name":"GPT-4o (2024-08-06)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-08-06","lastUpdated":"2024-08-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o-2024-11-20","name":"GPT-4o (2024-11-20)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":1.25,"cacheWrite":0.0},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt","knowledge":"2023-09","releaseDate":"2024-11-20","lastUpdated":"2024-11-20","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o-mini","name":"GPT-4o mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Small omni GPT for cheap multimodal assistance and production-scale traffic","family":"gpt-mini","knowledge":"2023-09","releaseDate":"2024-07-18","lastUpdated":"2024-07-18","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-4o-mini-2024-07-18","name":"GPT-4o-mini (2024-07-18)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Compact GPT model for low-latency assistance and high-volume workloads","family":"o-mini","knowledge":"2023-10-31","releaseDate":"2024-07-18","lastUpdated":"2024-07-18","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5","name":"GPT-5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Original GPT-5 workhorse for reasoning, coding, writing, and tool workflows","family":"gpt","knowledge":"2024-09-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5-mini","name":"GPT-5 Mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":2.0,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Small GPT-5 for responsive agents, coding help, and everyday automation","family":"gpt-mini","knowledge":"2024-05-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5-nano","name":"GPT-5 Nano","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high"},"cost":{"input":0.05,"output":0.4,"cacheRead":0.005,"cacheWrite":0.0},"metadata":{"description":"Tiny GPT-5 lane for routing, extraction, classification, and bulk jobs","family":"gpt-nano","knowledge":"2024-05-30","releaseDate":"2025-08-07","lastUpdated":"2025-08-07","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5-pro","name":"GPT-5 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{"high":"high"},"cost":{"input":15.0,"output":120.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Higher-accuracy GPT-5 tier for tough analysis, coding reviews, and planning","family":"gpt-pro","knowledge":"2024-09-30","releaseDate":"2025-10-06","lastUpdated":"2025-10-06","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.1","name":"GPT-5.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Sharper GPT-5 generation for coding, product work, and tool-assisted tasks","family":"gpt","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.1-codex","name":"GPT-5.1 Codex","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.13,"cacheWrite":0.0},"metadata":{"description":"Codex GPT for repository edits, code review, and practical software agents","family":"gpt-codex","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.25,"output":10.0,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.1-codex-mini","name":"GPT-5.1 Codex mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.25,"output":2.0,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2024-09-30","releaseDate":"2025-11-13","lastUpdated":"2025-11-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.2","name":"GPT-5.2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Reliable GPT generation for broad coding, writing, and tool-assisted product work","family":"gpt","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.2-chat","name":"GPT-5.2 Chat","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["image","text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Chat-tuned GPT model for conversational assistance, writing, and tool workflows","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2025-12-10","lastUpdated":"2025-12-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.2-codex","name":"GPT-5.2 Codex","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high","xhigh"],"reasoningValues":{"low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Code-specialist GPT for repository edits, reviews, and long-running software agents","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.2-pro","name":"GPT-5.2 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":21.0,"output":168.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Higher-accuracy GPT-5.2 variant for tougher reasoning and review workflows","family":"gpt-pro","knowledge":"2025-08-31","releaseDate":"2025-12-11","lastUpdated":"2025-12-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.3-chat","name":"GPT-5.3 Chat","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Chat-tuned GPT model for conversational assistance, writing, and tool workflows","family":"gpt","releaseDate":"2026-03-03","lastUpdated":"2026-03-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.3-codex","name":"GPT-5.3 Codex","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":1.75,"output":14.0,"cacheRead":0.175,"cacheWrite":0.0},"metadata":{"description":"Coding-optimized GPT model for repository edits, reviews, and agentic software work","family":"gpt-codex","knowledge":"2025-08-31","releaseDate":"2026-02-05","lastUpdated":"2026-02-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.4","name":"GPT-5.4","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":2.5,"output":15.0,"cacheRead":0.25,"cacheWrite":0.0,"tiers":[{"above":272000,"input":5.0,"output":22.5,"cacheRead":0.5,"cacheWrite":0.0}]},"metadata":{"description":"Agent-ready GPT for coding and computer-use workflows at a lower cost","family":"gpt","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-03-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.4-mini","name":"GPT-5.4 mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0.0},"metadata":{"description":"Strong small GPT for coding subagents, quick tool use, and high-volume work","family":"gpt-mini","knowledge":"2025-08-31","releaseDate":"2026-03-17","lastUpdated":"2026-03-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.4-nano","name":"GPT-5.4 nano","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":0.2,"output":1.25,"cacheRead":0.02,"cacheWrite":0.0},"metadata":{"description":"Cheapest GPT-5.4 lane for simple routing, extraction, and bulk automation","family":"gpt-nano","knowledge":"2025-08-31","releaseDate":"2026-03-17","lastUpdated":"2026-03-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.4-pro","name":"GPT-5.4 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":30.0,"output":180.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":272000,"input":60.0,"output":270.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"More exact GPT-5.4 tier for demanding professional reasoning and agent tasks","family":"gpt-pro","knowledge":"2025-08-31","releaseDate":"2026-03-05","lastUpdated":"2026-03-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.5","name":"GPT-5.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":0.0}]},"metadata":{"description":"Default frontier GPT for coding, computer use, research, and knowledge work","family":"gpt","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.5-pro","name":"GPT-5.5 Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["medium","high","xhigh"],"reasoningValues":{"medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":30.0,"output":180.0,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":272000,"input":60.0,"output":270.0,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Highest-accuracy GPT-5.5 tier for slower, precision-heavy reasoning and coding","family":"gpt-pro","knowledge":"2025-12-01","releaseDate":"2026-04-23","lastUpdated":"2026-04-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-luna","name":"GPT-5.6 Luna","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.1,"output":0.6,"cacheRead":0.01,"cacheWrite":0.125,"tiers":[{"above":272000,"input":0.2,"output":0.9,"cacheRead":0.02,"cacheWrite":0.25}]},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt-luna","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-luna-pro","name":"GPT-5.6 Luna Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":0.1,"output":0.6,"cacheRead":0.01,"cacheWrite":0.125,"tiers":[{"above":272000,"input":0.2,"output":0.9,"cacheRead":0.02,"cacheWrite":0.25}]},"metadata":{"description":"Frontier GPT model for professional reasoning, coding, and multimodal work","family":"gpt-luna","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-sol-pro","name":"GPT-5.6 Sol Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":12.5}]},"metadata":{"description":"Frontier GPT model for professional reasoning, coding, and multimodal work","family":"gpt-sol","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-terra","name":"GPT-5.6 Terra","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":1.0,"output":6.0,"cacheRead":0.1,"cacheWrite":1.25,"tiers":[{"above":272000,"input":2.0,"output":9.0,"cacheRead":0.2,"cacheWrite":2.5}]},"metadata":{"description":"GPT model for general reasoning, writing, coding, and tool-assisted tasks","family":"gpt-terra","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-5.6-terra-pro","name":"GPT-5.6 Terra Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","xhigh","max"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":1.0,"output":6.0,"cacheRead":0.1,"cacheWrite":1.25,"tiers":[{"above":272000,"input":2.0,"output":9.0,"cacheRead":0.2,"cacheWrite":2.5}]},"metadata":{"description":"Frontier GPT model for professional reasoning, coding, and multimodal work","family":"gpt-terra","knowledge":"2026-02-16","releaseDate":"2026-07-09","lastUpdated":"2026-07-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-audio","name":"GPT Audio","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":2.5,"output":10.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Speech generation model for controllable voice, narration, and audio delivery","family":"gpt","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-audio-mini","name":"GPT Audio Mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":128000,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.6,"output":2.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Speech generation model for controllable voice, narration, and audio delivery","family":"o-mini","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-chat-latest","name":"GPT Chat Latest","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":400000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"Chat-tuned GPT model for conversational assistance, writing, and tool workflows","family":"gpt","releaseDate":"2026-05-05","lastUpdated":"2026-05-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.037,"output":0.17,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open GPT reasoning model for self-hosted agents and controllable deployments","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.03,"output":0.13,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-20b:free","name":"gpt-oss-20b (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/gpt-oss-safeguard-20b","name":"gpt-oss-safeguard-20b","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.075,"output":0.3,"cacheRead":0.0375,"cacheWrite":0.0},"metadata":{"description":"Safety model for policy screening, moderation, and risk-aware routing workflows","family":"gpt-oss","releaseDate":"2025-10-29","lastUpdated":"2025-10-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o1","name":"o1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":15.0,"output":60.0,"cacheRead":7.5,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o","knowledge":"2023-09","releaseDate":"2024-12-05","lastUpdated":"2024-12-05","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o3","name":"o3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":8.0,"cacheRead":0.5,"cacheWrite":0.0},"metadata":{"description":"Deliberate o-series reasoner for hard math, coding, and multi-step analysis","family":"o","knowledge":"2024-05","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o3-mini","name":"o3-mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0.0},"metadata":{"description":"Smaller o-series reasoner for economical coding, math, and planning tasks","family":"o-mini","knowledge":"2024-05","releaseDate":"2024-12-20","lastUpdated":"2025-01-29","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o3-mini-high","name":"o3 Mini High","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{"high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o","knowledge":"2023-10-31","releaseDate":"2025-02-12","lastUpdated":"2025-02-12","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o3-pro","name":"o3-pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":20.0,"output":80.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"High-effort o3 tier for difficult technical reasoning and careful answers","family":"o-pro","knowledge":"2024-05","releaseDate":"2025-06-10","lastUpdated":"2025-06-10","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o4-mini","name":"o4-mini","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.275,"cacheWrite":0.0},"metadata":{"description":"Fast o-series model for compact reasoning, coding, and tool use","family":"o-mini","knowledge":"2024-05","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openai/o4-mini-high","name":"o4 Mini High","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":100000,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{"high":"high"},"cost":{"input":1.1,"output":4.4,"cacheRead":0.275,"cacheWrite":0.0},"metadata":{"description":"O-series reasoning model for hard analysis, math, coding, and planning","family":"o","knowledge":"2024-06-30","releaseDate":"2025-04-16","lastUpdated":"2025-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":true,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openrouter/auto","name":"Auto Router","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":2000000,"maximumOutput":1999999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Image model for prompt-driven generation, editing, and visual design workflows","family":"auto","releaseDate":"2023-11-08","lastUpdated":"2023-11-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"openrouter/free","name":"Free Models Router","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":200000,"maximumOutput":8000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","releaseDate":"2026-02-01","lastUpdated":"2026-02-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"poolside/laguna-s-2.1","name":"Laguna S 2.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.09,"output":0.18,"cacheRead":0.009,"cacheWrite":0.0},"metadata":{"description":"Reasoning model for deliberate analysis, multi-step problem solving, and tool use","family":"laguna-s","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"poolside/laguna-s-2.1:free","name":"Laguna S 2.1 (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Free provider route for experiments, demos, and cost-sensitive chat workloads","family":"laguna-s","releaseDate":"2026-07-21","lastUpdated":"2026-07-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"poolside/laguna-xs-2.1","name":"Laguna XS 2.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.06,"output":0.12,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Agentic coding model from Poolside in the XS size class for local deployment","family":"laguna","releaseDate":"2026-07-02","lastUpdated":"2026-07-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"poolside/laguna-xs-2.1:free","name":"Laguna XS 2.1 (free)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Free provider route for experiments, demos, and cost-sensitive chat workloads","family":"laguna","releaseDate":"2026-07-02","lastUpdated":"2026-07-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen-2.5-72b-instruct","name":"Qwen2.5 72B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":32768,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.36,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2024-06-30","releaseDate":"2024-09-19","lastUpdated":"2024-09-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen-2.5-7b-instruct","name":"Qwen2.5 7B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":32768,"maximumOutput":32767,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2024-06-30","releaseDate":"2024-10-16","lastUpdated":"2024-10-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen-plus","name":"Qwen Plus","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.26,"output":0.78,"cacheRead":0.052,"cacheWrite":0.325,"tiers":[{"above":256000,"input":0.78,"output":2.34,"cacheRead":0.156,"cacheWrite":0.975}]},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2024-04","releaseDate":"2024-01-25","lastUpdated":"2025-09-11","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen-plus-2025-07-28","name":"Qwen Plus 0728","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.26,"output":0.78,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":256000,"input":0.78,"output":2.34,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-09-08","lastUpdated":"2025-09-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen-plus-2025-07-28:thinking","name":"Qwen Plus 0728 (thinking)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.4,"output":1.2,"cacheRead":0.0,"cacheWrite":0.5,"tiers":[{"above":256000,"input":1.2,"output":3.6,"cacheRead":0.0,"cacheWrite":1.5}]},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-09-08","lastUpdated":"2025-09-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-14b","name":"Qwen3 14B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":8192,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2275,"output":0.91,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-04-28","lastUpdated":"2025-04-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-235b-a22b","name":"Qwen3 235B-A22B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.455,"output":1.82,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Large open Qwen MoE for multilingual reasoning, coding, and tool use","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-235b-a22b-2507","name":"Qwen3 235B A22B Instruct 2507","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.09,"output":0.55,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-06-30","releaseDate":"2025-07-21","lastUpdated":"2025-07-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-235b-a22b-thinking-2507","name":"Qwen3 235B A22B Thinking 2507","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.23,"output":2.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","knowledge":"2025-06-30","releaseDate":"2025-07-25","lastUpdated":"2025-07-25","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-30b-a3b","name":"Qwen3 30B A3B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.12,"output":0.5,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-04-28","lastUpdated":"2025-04-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-30b-a3b-instruct-2507","name":"Qwen3 30B A3B Instruct 2507","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32000,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.04815,"output":0.19305,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-06-30","releaseDate":"2025-07-29","lastUpdated":"2025-07-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-30b-a3b-thinking-2507","name":"Qwen3 30B A3B Thinking 2507","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":81920,"maximumOutput":32768,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":2.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","knowledge":"2025-06-30","releaseDate":"2025-08-28","lastUpdated":"2025-08-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-32b","name":"Qwen3 32B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.08,"output":0.28,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Dense open Qwen model for self-hosted chat, reasoning, and coding","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-8b","name":"Qwen3 8B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":8192,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.117,"output":0.455,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-04-28","lastUpdated":"2025-04-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-coder","name":"Qwen3 Coder 480B A35B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":1.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-06-30","releaseDate":"2025-07-23","lastUpdated":"2025-07-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-coder-30b-a3b-instruct","name":"Qwen3-Coder 30B-A3B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.07,"output":0.27,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Smaller Qwen coder for efficient local agents and repo-level fixes","family":"qwen","knowledge":"2025-04","releaseDate":"2025-04","lastUpdated":"2025-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-coder-flash","name":"Qwen3 Coder Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.195,"output":0.975,"cacheRead":0.039,"cacheWrite":0.24375,"tiers":[{"above":32000,"input":0.325,"output":1.625,"cacheRead":0.065,"cacheWrite":0.40625},{"above":128000,"input":0.52,"output":2.6,"cacheRead":0.104,"cacheWrite":0.65}]},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-coder-next","name":"Qwen3 Coder Next","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.12,"output":0.8,"cacheRead":0.07,"cacheWrite":0.0},"metadata":{"description":"Qwen coding model for software agents, repository edits, and code reasoning","family":"qwen","knowledge":"2025-09","releaseDate":"2026-02-03","lastUpdated":"2026-02-03","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-coder-plus","name":"Qwen3 Coder Plus","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.65,"output":3.25,"cacheRead":0.13,"cacheWrite":0.8125,"tiers":[{"above":32000,"input":1.17,"output":5.85,"cacheRead":0.234,"cacheWrite":1.4625},{"above":128000,"input":1.95,"output":9.75,"cacheRead":0.39,"cacheWrite":2.4375}]},"metadata":{"description":"Hosted Qwen coder for software agents, repo edits, and long-context code","family":"qwen","knowledge":"2025-04","releaseDate":"2025-07-23","lastUpdated":"2025-07-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-max","name":"Qwen3 Max","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.78,"output":3.9,"cacheRead":0.156,"cacheWrite":0.975,"tiers":[{"above":32000,"input":1.56,"output":7.8,"cacheRead":0.312,"cacheWrite":1.95},{"above":128000,"input":1.95,"output":9.75,"cacheRead":0.39,"cacheWrite":2.4375}]},"metadata":{"description":"Flagship Qwen3 model for coding agents, complex reasoning, and tool use","family":"qwen","knowledge":"2025-04","releaseDate":"2025-09-23","lastUpdated":"2025-09-23","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-max-thinking","name":"Qwen3 Max Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.78,"output":3.9,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":32000,"input":1.56,"output":7.8,"cacheRead":0.0,"cacheWrite":0.0},{"above":128000,"input":1.95,"output":9.75,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Qwen reasoning model for deliberate problem solving, math, and coding","family":"qwen","releaseDate":"2026-02-09","lastUpdated":"2026-02-09","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-next-80b-a3b-instruct","name":"Qwen3-Next 80B-A3B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.09,"output":1.1,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","knowledge":"2025-04","releaseDate":"2025-09","lastUpdated":"2025-09","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-next-80b-a3b-thinking","name":"Qwen3-Next 80B-A3B (Thinking)","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.15,"output":1.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Qwen thinking model for local reasoning, math, and coding agents","family":"qwen","knowledge":"2025-04","releaseDate":"2025-09","lastUpdated":"2025-09","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-235b-a22b-instruct","name":"Qwen3 VL 235B A22B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.21,"output":1.9,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-09-23","lastUpdated":"2025-09-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-235b-a22b-thinking","name":"Qwen3 VL 235B A22B Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.4,"output":4.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-09-23","lastUpdated":"2025-09-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-30b-a3b-instruct","name":"Qwen3 VL 30B A3B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-10-06","lastUpdated":"2025-10-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-30b-a3b-thinking","name":"Qwen3 VL 30B A3B Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":2.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-03-31","releaseDate":"2025-10-06","lastUpdated":"2025-10-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-32b-instruct","name":"Qwen3 VL 32B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.104,"output":0.416,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2025-10-23","lastUpdated":"2025-10-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-8b-instruct","name":"Qwen3 VL 8B Instruct","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.117,"output":0.455,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2025-10-14","lastUpdated":"2025-10-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3-vl-8b-thinking","name":"Qwen3 VL 8B Thinking","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["image","text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.18,"output":2.1,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2025-10-14","lastUpdated":"2025-10-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-122b-a10b","name":"Qwen3.5 122B-A10B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":81920,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.29,"output":2.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-27b","name":"Qwen3.5 27B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.195,"output":1.56,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-35b-a3b","name":"Qwen3.5 35B-A3B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":1.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-397b-a17b","name":"Qwen3.5 397B-A17B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.39,"output":2.34,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Large open Qwen multimodal MoE for visual agents and long technical tasks","family":"qwen","releaseDate":"2026-02-15","lastUpdated":"2026-02-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-9b","name":"Qwen3.5 9B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.15,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2026-02-23","lastUpdated":"2026-02-23","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-flash-02-23","name":"Qwen3.5-Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.065,"output":0.26,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-02-25","lastUpdated":"2026-02-25","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-plus-02-15","name":"Qwen3.5 Plus 2026-02-15","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.26,"output":1.56,"cacheRead":0.0,"cacheWrite":0.0,"tiers":[{"above":256000,"input":0.325,"output":1.95,"cacheRead":0.0,"cacheWrite":0.0}]},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","knowledge":"2025-04","releaseDate":"2026-02-16","lastUpdated":"2026-02-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.5-plus-20260420","name":"Qwen3.5 Plus 2026-04-20","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.8,"cacheRead":0.0,"cacheWrite":0.375,"tiers":[{"above":256000,"input":0.375,"output":2.25,"cacheRead":0.0,"cacheWrite":0.46875}]},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen3.5","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-27b","name":"Qwen3.6 27B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.6,"cacheRead":0.12,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-35b-a3b","name":"Qwen3.6 35B-A3B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":1.0,"cacheRead":0.05,"cacheWrite":0.0},"metadata":{"description":"Open multimodal Qwen MoE for local agents that need vision, audio, and code","family":"qwen","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-flash","name":"Qwen3.6 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1875,"output":1.125,"cacheRead":0.0,"cacheWrite":0.234375,"tiers":[{"above":256000,"input":0.75,"output":3.0,"cacheRead":0.0,"cacheWrite":0.9375}]},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen3.6","releaseDate":"2026-04-27","lastUpdated":"2026-04-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-max-preview","name":"Qwen3.6 Max Preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.027,"output":6.162,"cacheRead":0.0,"cacheWrite":1.28375,"tiers":[{"above":128000,"input":1.58,"output":9.48,"cacheRead":0.0,"cacheWrite":1.975}]},"metadata":{"description":"Flagship Qwen model for complex reasoning, coding, and agentic workflows","family":"qwen","knowledge":"2025-04","releaseDate":"2026-04-20","lastUpdated":"2026-04-20","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.6-plus","name":"Qwen3.6 Plus","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.325,"output":1.95,"cacheRead":0.0,"cacheWrite":0.40625,"tiers":[{"above":256000,"input":1.3,"output":3.9,"cacheRead":0.0,"cacheWrite":1.625}]},"metadata":{"description":"Earlier Qwen multimodal workhorse for million-token agent and document tasks","family":"qwen","knowledge":"2025-04","releaseDate":"2026-04-02","lastUpdated":"2026-04-02","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.7-flash","name":"Qwen3.7 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.03,"output":0.13,"cacheRead":0.006,"cacheWrite":0.038,"tiers":[{"above":32000,"input":0.1,"output":0.4,"cacheRead":0.02,"cacheWrite":0.125},{"above":256000,"input":0.2,"output":0.8,"cacheRead":0.04,"cacheWrite":0.25}]},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.7-max","name":"Qwen3.7 Max","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.475,"output":4.425,"cacheRead":0.295,"cacheWrite":1.84375},"metadata":{"description":"Qwen frontier model tuned for agent frameworks, coding assistants, and long tasks","family":"qwen","releaseDate":"2026-05-21","lastUpdated":"2026-05-21","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.7-plus","name":"Qwen3.7 Plus","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.32,"output":1.28,"cacheRead":0.064,"cacheWrite":0.4,"tiers":[{"above":256000,"input":0.96,"output":3.84,"cacheRead":0.192,"cacheWrite":1.2}]},"metadata":{"description":"Multimodal Qwen workhorse for long-context agents, visual inputs, and coding","family":"qwen","knowledge":"2025-04","releaseDate":"2026-06-02","lastUpdated":"2026-06-02","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"qwen/qwen3.8-max","name":"Qwen3.8 Max","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["minimal","low","medium","high","xhigh"],"reasoningValues":{"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"},"cost":{"input":2.0,"output":6.0,"cacheRead":0.25,"cacheWrite":2.5},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-08-03","lastUpdated":"2026-08-03","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"rekaai/reka-edge","name":"Reka Edge","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":16384,"maximumOutput":16383,"input":["image","text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.1,"output":0.1,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Multimodal model for analyzing text, images, documents, and rich media","family":"reka","releaseDate":"2026-03-20","lastUpdated":"2026-03-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"relace/relace-search","name":"Relace Search","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":128000,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.0,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Tool-capable chat model for instruction following and agentic application workflows","releaseDate":"2025-12-08","lastUpdated":"2025-12-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"sakana/fugu-ultra","name":"Fugu Ultra","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":128000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","xhigh","max"],"reasoningValues":{"high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":5.0,"output":30.0,"cacheRead":0.5,"cacheWrite":0.0,"tiers":[{"above":272000,"input":10.0,"output":45.0,"cacheRead":1.0,"cacheWrite":0.0}]},"metadata":{"description":"Quality-first multi-agent model for hard research, analysis, and competitions","family":"fugu","releaseDate":"2026-06-15","lastUpdated":"2026-06-15","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"sao10k/l3.1-euryale-70b","name":"Llama 3.1 Euryale 70B v2.2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.85,"output":0.85,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Llama instruction model for multilingual chat, reasoning, and coding","family":"llama","knowledge":"2023-12-31","releaseDate":"2024-08-28","lastUpdated":"2024-08-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"stepfun/step-3.5-flash","name":"Step 3.5 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.1,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"StepFun flash lane for quick multimodal reasoning and coding assistance","knowledge":"2025-01","releaseDate":"2026-01-29","lastUpdated":"2026-02-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"stepfun/step-3.7-flash","name":"Step 3.7 Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":256000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.2,"output":1.15,"cacheRead":0.04,"cacheWrite":0.0},"metadata":{"description":"Newer StepFun flash model for faster agents, coding, and multimodal prompts","knowledge":"2026-03-01","releaseDate":"2026-05-29","lastUpdated":"2026-05-29","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"tencent/hy3","name":"Hy3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":128000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","high"],"reasoningValues":{"off":"none","low":"low","high":"high"},"cost":{"input":0.132,"output":0.528,"cacheRead":0.033,"cacheWrite":0.0},"metadata":{"description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","releaseDate":"2026-07-06","lastUpdated":"2026-07-06","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"tencent/hy3-preview","name":"Hy3 preview","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":262144,"maximumOutput":262143,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","low","high"],"reasoningValues":{"off":"none","low":"low","high":"high"},"cost":{"input":0.063,"output":0.21,"cacheRead":0.021,"cacheWrite":0.0},"metadata":{"description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","releaseDate":"2026-04-20","lastUpdated":"2026-04-20","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"thedrummer/unslopnemo-12b","name":"UnslopNemo 12B","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1024000,"maximumOutput":1023999,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.4,"output":0.4,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight instruction model for adaptable chat and self-hosted production workloads","knowledge":"2024-04-30","releaseDate":"2024-11-08","lastUpdated":"2024-11-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"thinkingmachines/inkling","name":"Inkling","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":262144,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":0.95,"output":4.05,"cacheRead":0.16,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"ling","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"thinkingmachines/inkling-small","name":"Inkling Small","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":524288,"maximumOutput":262144,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high","max"],"reasoningValues":{"off":"none","minimal":"minimal","low":"low","medium":"medium","high":"high","max":"max"},"cost":{"input":0.45,"output":1.2,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Multimodal reasoning model for visual analysis, planning, and tool use","family":"ling","releaseDate":"2026-07-30","lastUpdated":"2026-07-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"upstage/solar-pro-3","name":"Solar Pro 3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.15,"output":0.6,"cacheRead":0.015,"cacheWrite":0.0},"metadata":{"description":"Flagship model for demanding analysis, coding, and production agent workflows","family":"solar-pro","releaseDate":"2026-01-27","lastUpdated":"2026-01-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"x-ai/grok-4.20","name":"Grok 4.20","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":2000000,"maximumOutput":1999999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.25,"output":2.5,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":5.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Grok model for agentic tool use, reasoning, coding, and live assistance","family":"grok","knowledge":"2025-09-01","releaseDate":"2026-03-31","lastUpdated":"2026-03-31","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"x-ai/grok-4.3","name":"Grok 4.3","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1000000,"maximumOutput":999999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":2.5,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":5.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"xAI's default Grok for chat, coding, agentic tools, and lower hallucination risk","family":"grok","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"x-ai/grok-4.5","name":"Grok 4.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":500000,"maximumOutput":499999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":6.0,"cacheRead":0.3,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":12.0,"cacheRead":0.6,"cacheWrite":0.0}]},"metadata":{"description":"xAI's latest Grok for chat, coding, agentic tools, and lower hallucination risk","family":"grok","releaseDate":"2026-07-08","lastUpdated":"2026-07-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"x-ai/grok-build-0.1","name":"Grok Build 0.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":2.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.0,"output":4.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Fast Grok coding model tuned for agentic engineering and iterative edits","family":"grok-build","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"xiaomi/mimo-v2.5","name":"MiMo-V2.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.14,"output":0.28,"cacheRead":0.0028,"cacheWrite":0.0},"metadata":{"description":"Open MiMo model for multimodal coding agents and long-context automation","family":"mimo","knowledge":"2024-12","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"xiaomi/mimo-v2.5-pro","name":"MiMo-V2.5-Pro","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1050000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.435,"output":0.87,"cacheRead":0.0036,"cacheWrite":0.0},"metadata":{"description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","knowledge":"2024-12","releaseDate":"2026-04-22","lastUpdated":"2026-04-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.5","name":"GLM-4.5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Hybrid-reasoning GLM release that made the 4.5 line broadly useful","family":"glm","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.5-air","name":"GLM-4.5-Air","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.13,"output":0.85,"cacheRead":0.025,"cacheWrite":0.0},"metadata":{"description":"Lighter GLM-4.5 variant for fast coding assistance and cheaper agents","family":"glm-air","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.5v","name":"GLM-4.5V","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":65536,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":1.8,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-08-11","lastUpdated":"2025-08-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.6","name":"GLM-4.6","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.5,"output":2.0,"cacheRead":0.1,"cacheWrite":0.0},"metadata":{"description":"Late GLM-4 workhorse for coding agents, reasoning, and structured tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-09-30","lastUpdated":"2025-09-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.6v","name":"GLM-4.6V","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":131072,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.055,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-08","lastUpdated":"2025-12-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.4,"output":1.75,"cacheRead":0.08,"cacheWrite":0.0},"metadata":{"description":"Mature GLM model for dependable coding, reasoning, and structured agent tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-4.7-flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":202752,"maximumOutput":16384,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.06,"output":0.4,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Budget GLM lane for fast coding help, routing, and everyday automation","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_details"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-5","name":"GLM-5","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.95,"output":2.55,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"General GLM flagship for coding, analysis, and tool-heavy engineering workflows","family":"glm","releaseDate":"2026-02-12","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-5-turbo","name":"GLM-5-Turbo","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":202752,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.2,"output":4.0,"cacheRead":0.24,"cacheWrite":0.0},"metadata":{"description":"Faster GLM-5 lane for coding agents that need lower latency","family":"glm","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-5.1","name":"GLM-5.1","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.952,"output":2.992,"cacheRead":0.1768,"cacheWrite":0.0},"metadata":{"description":"Strong GLM coding model for agentic engineering, terminals, and repository generation","family":"glm","releaseDate":"2026-04-07","lastUpdated":"2026-04-07","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":1048576,"maximumOutput":128000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","xhigh"],"reasoningValues":{"high":"high","xhigh":"xhigh"},"cost":{"input":0.308,"output":0.968,"cacheRead":0.0572,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}},{"id":"z-ai/glm-5v-turbo","name":"GLM-5V-Turbo","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","contextWindow":202752,"maximumOutput":131072,"input":["image","text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.2,"output":4.0,"cacheRead":0.24,"cacheWrite":0.0},"metadata":{"description":"Fast GLM vision model for screenshots, documents, and multimodal agent tasks","family":"glm","releaseDate":"2026-04-01","lastUpdated":"2026-04-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":true,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openrouter","supportsLongCacheRetention":true}}]},{"id":"togetherai","name":"Together AI","endpoint":"https://api.together.ai/v1","metadata":{"documentation":"https://docs.together.ai/docs/serverless-models","environmentVariables":"TOGETHER_API_KEY"},"models":[{"id":"deepseek-ai/DeepSeek-V4-Flash-0731","name":"DeepSeek V4 Flash 0731","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":1000000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.14,"output":0.28,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Official DeepSeek V4 Flash release with enhanced agentic capabilities and integrated DSpark speculative decoding","family":"deepseek-flash","knowledge":"2025-05","releaseDate":"2026-07-31","lastUpdated":"2026-07-31","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"deepseek-ai/DeepSeek-V4-Pro","name":"DeepSeek V4 Pro","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":512000,"maximumOutput":384000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":1.74,"output":3.48,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Flagship DeepSeek model for coding, reasoning, and agentic work","family":"deepseek","releaseDate":"2026-04-24","lastUpdated":"2026-04-24","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"google/gemma-4-31B-it","name":"Gemma 4 31B Instruct","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.39,"output":0.97,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open Gemma instruction model for efficient chat and self-hosted deployments","family":"gemma","knowledge":"2025-01","releaseDate":"2026-04-07","lastUpdated":"2026-04-07","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.04,"output":1.04,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","knowledge":"2023-12","releaseDate":"2024-12-06","lastUpdated":"2026-07-02","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"MiniMaxAI/MiniMax-M2.7","name":"MiniMax-M2.7","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":202752,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax model for chat, coding, office work, and agentic tasks","family":"minimax","releaseDate":"2026-03-18","lastUpdated":"2026-03-18","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"MiniMaxAI/MiniMax-M3","name":"MiniMax-M3","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":524288,"maximumOutput":250000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0.0},"metadata":{"description":"MiniMax multimodal coding model for long-context reasoning and agent tasks","family":"minimax","releaseDate":"2026-06-12","lastUpdated":"2026-06-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"moonshotai/Kimi-K2.6","name":"Kimi K2.6","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":262144,"maximumOutput":131000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":1.2,"output":4.5,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Kimi multimodal agent model for visual understanding, coding, and planning","family":"kimi-k2","knowledge":"2025-01","releaseDate":"2026-04-21","lastUpdated":"2026-04-21","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"moonshotai/Kimi-K2.7-Code","name":"Kimi K2.7 Code","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":262144,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.95,"output":4.0,"cacheRead":0.19,"cacheWrite":0.0},"metadata":{"description":"Kimi coding model for software agents, refactors, and repository reasoning","family":"kimi-k2","releaseDate":"2026-06-14","lastUpdated":"2026-06-14","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"moonshotai/Kimi-K3","name":"Kimi K3","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":1048576,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":3.0,"output":15.0,"cacheRead":0.3,"cacheWrite":0.0},"metadata":{"description":"Multimodal Kimi model with 1M context and toggleable max-effort thinking for long-horizon agent work","family":"kimi-k3","releaseDate":"2026-07-16","lastUpdated":"2026-07-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":false,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"nvidia/nemotron-3-ultra-550b-a55b","name":"Nemotron 3 Ultra 550B A55B","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":512300,"maximumOutput":512299,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.6,"output":3.6,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"Largest Nemotron 3 model for maximum open-weight reasoning and agent accuracy","family":"nemotron","releaseDate":"2026-06-04","lastUpdated":"2026-06-04","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.15,"output":0.6,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","knowledge":"2025-08","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"openai/gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":131072,"maximumOutput":131071,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":0.05,"output":0.2,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open-weight GPT model for self-hosted reasoning and instruction-following workloads","family":"gpt-oss","releaseDate":"2025-08-05","lastUpdated":"2025-08-05","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"Qwen/Qwen2.5-7B-Instruct-Turbo","name":"Qwen 2.5 7B Instruct Turbo","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":32768,"maximumOutput":32767,"input":["text","structured"],"output":["text","structured","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":0.3,"output":0.3,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient Qwen model for fast chat, extraction, and high-volume workloads","family":"qwen","releaseDate":"2024-09-19","lastUpdated":"2024-09-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"Qwen/Qwen3.5-9B","name":"Qwen3.5 9B","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":262144,"maximumOutput":65536,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.17,"output":0.25,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen vision-language model for visual reasoning, documents, and agent tasks","family":"qwen","releaseDate":"2026-03-03","lastUpdated":"2026-03-03","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"Qwen/Qwen3.6-Plus","name":"Qwen3.6 Plus","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":1000000,"maximumOutput":500000,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","high"],"reasoningValues":{},"cost":{"input":0.5,"output":3.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Qwen instruction model for multilingual chat, reasoning, and tool use","family":"qwen","releaseDate":"2026-04-30","lastUpdated":"2026-04-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"Qwen/Qwen3.7-Max","name":"Qwen3.7 Max","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":1000000,"maximumOutput":500000,"input":["text","structured"],"output":["text","tools"],"reasoning":["off"],"reasoningValues":{},"cost":{"input":1.25,"output":3.75,"cacheRead":0.125,"cacheWrite":0.0},"metadata":{"description":"Flagship Qwen model for complex reasoning, coding, and agentic workflows","family":"qwen","releaseDate":"2026-05-21","lastUpdated":"2026-07-02","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"thinkingmachines/Inkling","name":"Inkling","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":524288,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","high","xhigh","max"],"reasoningValues":{"off":"none","high":"high","xhigh":"xhigh","max":"max"},"cost":{"input":1.0,"output":4.05,"cacheRead":0.17,"cacheWrite":0.0},"metadata":{"description":"Multimodal MoE reasoning model (975B total, 41B active) for text, image, and audio","family":"ling","releaseDate":"2026-07-15","lastUpdated":"2026-07-15","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}},{"id":"zai-org/GLM-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://api.together.ai/v1","contextWindow":262144,"maximumOutput":164000,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-16","lastUpdated":"2026-06-16","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"together","supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":false}}]},{"id":"xai","name":"xAI","endpoint":"https://api.x.ai/v1","metadata":{"documentation":"https://docs.x.ai/docs/models","environmentVariables":"XAI_API_KEY"},"models":[{"id":"grok-4.3","name":"Grok 4.3","api":"openai-completions","baseUrl":"https://api.x.ai/v1","contextWindow":1000000,"maximumOutput":30000,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high"],"reasoningValues":{"off":"none","low":"low","medium":"medium","high":"high"},"cost":{"input":1.25,"output":2.5,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.5,"output":5.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"xAI's Grok for chat, coding, agentic tools, and lower hallucination risk","family":"grok","releaseDate":"2026-04-17","lastUpdated":"2026-04-17","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"grok-4.5","name":"Grok 4.5","api":"openai-responses","baseUrl":"https://api.x.ai/v1","contextWindow":500000,"maximumOutput":499999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["low","medium","high"],"reasoningValues":{"low":"low","medium":"medium","high":"high"},"cost":{"input":2.0,"output":6.0,"cacheRead":0.3,"cacheWrite":0.0,"tiers":[{"above":200000,"input":4.0,"output":12.0,"cacheRead":0.6,"cacheWrite":0.0}]},"metadata":{"description":"xAI's latest Grok for chat, coding, agentic tools, and lower hallucination risk","family":"grok","releaseDate":"2026-07-08","lastUpdated":"2026-07-08","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsDeveloperRole":true,"supportsStrictMode":false,"supportsOpenAIGrammarTools":false,"supportsAdditionalTools":false,"supportsToolSearch":false,"supportsExplicitPromptCacheMode":false,"supportsLongCacheRetention":false,"sessionAffinityFormat":"openai"}},{"id":"grok-build-0.1","name":"Grok Build 0.1","api":"openai-completions","baseUrl":"https://api.x.ai/v1","contextWindow":256000,"maximumOutput":255999,"input":["text","image","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":2.0,"cacheRead":0.2,"cacheWrite":0.0,"tiers":[{"above":200000,"input":2.0,"output":4.0,"cacheRead":0.4,"cacheWrite":0.0}]},"metadata":{"description":"Fast Grok coding model tuned for agentic engineering and iterative edits","family":"grok-build","releaseDate":"2026-04-16","lastUpdated":"2026-04-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_completion_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"openai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}}]},{"id":"zai","name":"Z.AI","endpoint":"https://api.z.ai/api/paas/v4","metadata":{"documentation":"https://docs.z.ai/guides/overview/pricing","environmentVariables":"ZHIPU_API_KEY"},"models":[{"id":"glm-4.5","name":"GLM-4.5","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Hybrid-reasoning GLM release that made the 4.5 line broadly useful","family":"glm","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5-air","name":"GLM-4.5-Air","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":1.1,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Lighter GLM-4.5 variant for fast coding assistance and cheaper agents","family":"glm-air","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5-flash","name":"GLM-4.5-Flash","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5v","name":"GLM-4.5V","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":64000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":1.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-08-11","lastUpdated":"2025-08-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.6","name":"GLM-4.6","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Late GLM-4 workhorse for coding agents, reasoning, and structured tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-09-30","lastUpdated":"2025-09-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.6v","name":"GLM-4.6V","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":128000,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-08","lastUpdated":"2025-12-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Mature GLM model for dependable coding, reasoning, and structured agent tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7-flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Budget GLM lane for fast coding help, routing, and everyday automation","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7-flashx","name":"GLM-4.7-FlashX","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.07,"output":0.4,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5","name":"GLM-5","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":3.2,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"General GLM flagship for coding, analysis, and tool-heavy engineering workflows","family":"glm","releaseDate":"2026-02-12","lastUpdated":"2026-02-12","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5-turbo","name":"GLM-5-Turbo","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.2,"output":4.0,"cacheRead":0.24,"cacheWrite":0.0},"metadata":{"description":"Faster GLM-5 lane for coding agents that need lower latency","family":"glm","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.1","name":"GLM-5.1","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Strong GLM coding model for agentic engineering, terminals, and repository generation","family":"glm","releaseDate":"2026-04-07","lastUpdated":"2026-04-07","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5v-turbo","name":"GLM-5V-Turbo","api":"openai-completions","baseUrl":"https://api.z.ai/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.2,"output":4.0,"cacheRead":0.24,"cacheWrite":0.0},"metadata":{"description":"Fast GLM vision model for screenshots, documents, and multimodal agent tasks","family":"glm","releaseDate":"2026-04-01","lastUpdated":"2026-04-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}}]},{"id":"zai-coding-plan","name":"Z.AI Coding Plan","endpoint":"https://api.z.ai/api/coding/paas/v4","metadata":{"documentation":"https://docs.z.ai/devpack/overview","environmentVariables":"ZHIPU_API_KEY"},"models":[{"id":"glm-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://api.z.ai/api/coding/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5-turbo","name":"GLM-5-Turbo","api":"openai-completions","baseUrl":"https://api.z.ai/api/coding/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://api.z.ai/api/coding/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2-highspeed","name":"GLM-5.2 Highspeed","api":"openai-completions","baseUrl":"https://api.z.ai/api/coding/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}}]},{"id":"zhipuai","name":"Zhipu AI","endpoint":"https://open.bigmodel.cn/api/paas/v4","metadata":{"documentation":"https://docs.z.ai/guides/overview/pricing","environmentVariables":"ZHIPU_API_KEY"},"models":[{"id":"glm-4.5","name":"GLM-4.5","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Hybrid-reasoning GLM release that made the 4.5 line broadly useful","family":"glm","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5-air","name":"GLM-4.5-Air","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.2,"output":1.1,"cacheRead":0.03,"cacheWrite":0.0},"metadata":{"description":"Lighter GLM-4.5 variant for fast coding assistance and cheaper agents","family":"glm-air","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5-flash","name":"GLM-4.5-Flash","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":131072,"maximumOutput":98304,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2025-07-28","lastUpdated":"2025-07-28","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.5v","name":"GLM-4.5V","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":64000,"maximumOutput":16384,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":1.8,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-08-11","lastUpdated":"2025-08-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true}},{"id":"glm-4.6","name":"GLM-4.6","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Late GLM-4 workhorse for coding agents, reasoning, and structured tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-09-30","lastUpdated":"2025-09-30","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.6v","name":"GLM-4.6V","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":128000,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-08","lastUpdated":"2025-12-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0.0},"metadata":{"description":"Mature GLM model for dependable coding, reasoning, and structured agent tasks","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7-flash","name":"GLM-4.7-Flash","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Budget GLM lane for fast coding help, routing, and everyday automation","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7-flashx","name":"GLM-4.7-FlashX","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.07,"output":0.4,"cacheRead":0.01,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm-flash","knowledge":"2025-04","releaseDate":"2026-01-19","lastUpdated":"2026-01-19","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5","name":"GLM-5","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.0,"output":3.2,"cacheRead":0.2,"cacheWrite":0.0},"metadata":{"description":"General GLM flagship for coding, analysis, and tool-heavy engineering workflows","family":"glm","releaseDate":"2026-02-11","lastUpdated":"2026-02-11","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.1","name":"GLM-5.1","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Strong GLM coding model for agentic engineering, terminals, and repository generation","family":"glm","releaseDate":"2026-03-27","lastUpdated":"2026-03-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5v-turbo","name":"GLM-5V-Turbo","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":5.0,"output":22.0,"cacheRead":1.2,"cacheWrite":0.0},"metadata":{"description":"Fast GLM vision model for screenshots, documents, and multimodal agent tasks","family":"glm","releaseDate":"2026-04-01","lastUpdated":"2026-04-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}}]},{"id":"zhipuai-coding-plan","name":"Zhipu AI Coding Plan","endpoint":"https://open.bigmodel.cn/api/coding/paas/v4","metadata":{"documentation":"https://docs.bigmodel.cn/cn/coding-plan/overview","environmentVariables":"ZHIPU_API_KEY"},"models":[{"id":"glm-4.6v","name":"GLM-4.6V","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":128000,"maximumOutput":32768,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.3,"output":0.9,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-08","lastUpdated":"2025-12-08","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-4.7","name":"GLM-4.7","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":204800,"maximumOutput":131072,"input":["text","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","knowledge":"2025-04","releaseDate":"2025-12-22","lastUpdated":"2025-12-22","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5-turbo","name":"GLM-5-Turbo","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Efficient GLM model for fast reasoning, coding, and agent workflows","family":"glm","releaseDate":"2026-03-16","lastUpdated":"2026-03-16","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.1","name":"GLM-5.1","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Flagship GLM model for hybrid reasoning, coding, and agentic engineering","family":"glm","releaseDate":"2026-03-27","lastUpdated":"2026-03-27","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2","name":"GLM-5.2","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["off","low","medium","high","max"],"reasoningValues":{"low":"high","medium":"high","high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5.2-highspeed","name":"GLM-5.2 Highspeed","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":1000000,"maximumOutput":131072,"input":["text","structured"],"output":["text","structured","tools","reasoning"],"reasoning":["high","max"],"reasoningValues":{"high":"high","max":"max"},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"Open flagship GLM for long-horizon coding agents and million-token context work","family":"glm","releaseDate":"2026-06-13","lastUpdated":"2026-06-13","openWeights":"true"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":true,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}},{"id":"glm-5v-turbo","name":"GLM-5V-Turbo","api":"openai-completions","baseUrl":"https://open.bigmodel.cn/api/coding/paas/v4","contextWindow":200000,"maximumOutput":131072,"input":["text","image","structured"],"output":["text","tools","reasoning"],"reasoning":["off","minimal","low","medium","high"],"reasoningValues":{},"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0},"metadata":{"description":"GLM vision model for visual reasoning, documents, and multimodal agents","family":"glm","releaseDate":"2026-04-01","lastUpdated":"2026-04-01","openWeights":"false"},"headers":{},"compatibility":{"supportsTemperature":true,"structuredOutput":false,"interleaved":{"field":"reasoning_content"},"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":true,"supportsFinishReason":true,"maxTokensField":"max_tokens","requiresToolResultName":false,"requiresAssistantAfterToolResult":false,"requiresThinkingAsText":false,"requiresReasoningContentOnAssistantMessages":false,"thinkingFormat":"zai","supportsStrictMode":true,"supportsOpenAIGrammarTools":false,"sendSessionAffinityHeaders":false,"sessionAffinityFormat":"openai","supportsLongCacheRetention":true,"zaiToolStream":true}}]}]} diff --git a/src/OpenGameAgent.Models/GameModelDirectory.cs b/src/OpenGameAgent.Models/GameModelDirectory.cs new file mode 100644 index 0000000..1d50cb3 --- /dev/null +++ b/src/OpenGameAgent.Models/GameModelDirectory.cs @@ -0,0 +1,487 @@ +using System.Collections.ObjectModel; +using System.Reflection; +using System.Text.Json; + +namespace OpenGameAgent.Models; + +public sealed class GameModelDirectorySnapshot +{ + private readonly IReadOnlyDictionary> _modelsByProvider; + + internal GameModelDirectorySnapshot( + string version, + DateTimeOffset generatedAt, + IReadOnlyList providers, + IReadOnlyList models) + { + Version = version; + GeneratedAt = generatedAt; + Providers = Array.AsReadOnly(providers.ToArray()); + Models = Array.AsReadOnly(models.ToArray()); + _modelsByProvider = new ReadOnlyDictionary>( + Models.GroupBy(model => model.ProviderId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)Array.AsReadOnly( + group.OrderBy(model => model.ModelId, StringComparer.Ordinal).ToArray()), + StringComparer.Ordinal)); + } + + public string Version { get; } + + public DateTimeOffset GeneratedAt { get; } + + public IReadOnlyList Providers { get; } + + public IReadOnlyList Models { get; } + + public IReadOnlyList GetModels(string providerId) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + return _modelsByProvider.TryGetValue(id, out var models) + ? models + : Array.Empty(); + } + + public GameProviderDescriptor? GetProvider(string providerId) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + return Providers.FirstOrDefault(provider => string.Equals(provider.ProviderId, id, StringComparison.Ordinal)); + } +} + +public static class GameModelDirectory +{ + private const string ResourceName = "OpenGameAgent.Models.Data.model-directory.json"; + private const int MaximumJsonCharacters = 20_000_000; + private const int MaximumProviders = 512; + private const int MaximumModels = 100_000; + private static readonly Lazy Bundled = + new(LoadBundledCore, LazyThreadSafetyMode.ExecutionAndPublication); + + public static GameModelDirectorySnapshot LoadBundled() => Bundled.Value; + + private static GameModelDirectorySnapshot LoadBundledCore() + { + using var stream = typeof(GameModelDirectory).GetTypeInfo().Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException("The bundled model directory resource is unavailable."); + using var reader = new StreamReader(stream); + return ParseJson(reader.ReadToEnd()); + } + + public static GameModelDirectorySnapshot ParseJson(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + if (json.Length is < 2 or > MaximumJsonCharacters) + { + throw new ArgumentException("The model directory JSON is outside its allowed size.", nameof(json)); + } + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + return ParseRoot(document.RootElement); + } + catch (JsonException exception) + { + throw new ArgumentException("The model directory JSON is invalid.", nameof(json), exception); + } + } + + private static GameModelDirectorySnapshot ParseRoot(JsonElement root) + { + RequireKind(root, JsonValueKind.Object, "root"); + var version = RequiredString(root, "version", 128); + var generatedAtText = RequiredString(root, "generatedAt", 128); + if (!DateTimeOffset.TryParse( + generatedAtText, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var generatedAt)) + { + throw new ArgumentException("The model directory generation time is invalid."); + } + + if (!root.TryGetProperty("providers", out var providersElement)) + { + throw new ArgumentException("The model directory omitted its providers."); + } + + RequireKind(providersElement, JsonValueKind.Array, "providers"); + if (providersElement.GetArrayLength() > MaximumProviders) + { + throw new ArgumentException("The model directory contains too many providers."); + } + + var providers = new List(); + var models = new List(); + var providerIds = new HashSet(StringComparer.Ordinal); + foreach (var providerElement in providersElement.EnumerateArray()) + { + RequireKind(providerElement, JsonValueKind.Object, "provider"); + var providerId = RequiredString(providerElement, "id", 256); + if (!providerIds.Add(providerId)) + { + throw new ArgumentException($"The model directory contains duplicate provider '{providerId}'."); + } + + var endpointText = OptionalString(providerElement, "endpoint", 4096); + var provider = new GameProviderDescriptor( + providerId, + OptionalString(providerElement, "name", 4096) ?? providerId, + endpointText is null ? null : new Uri(endpointText, UriKind.Absolute), + OptionalBoolean(providerElement, "local") ?? false, + supportsDynamicModels: false, + ParseStringMap(providerElement, "metadata")); + providers.Add(provider); + + if (!providerElement.TryGetProperty("models", out var modelsElement)) + { + throw new ArgumentException($"Provider '{providerId}' omitted its models."); + } + + RequireKind(modelsElement, JsonValueKind.Array, "models"); + var modelIds = new HashSet(StringComparer.Ordinal); + foreach (var modelElement in modelsElement.EnumerateArray()) + { + if (models.Count >= MaximumModels) + { + throw new ArgumentException("The model directory contains too many models."); + } + + var model = ParseModel(provider, modelElement); + if (!modelIds.Add(model.ModelId)) + { + throw new ArgumentException( + $"Provider '{providerId}' contains duplicate model '{model.ModelId}'."); + } + + models.Add(model); + } + } + + return new GameModelDirectorySnapshot(version, generatedAt, providers, models); + } + + private static GameModelDescriptor ParseModel(GameProviderDescriptor provider, JsonElement element) + { + RequireKind(element, JsonValueKind.Object, "model"); + var modelBaseUrl = OptionalString(element, "baseUrl", 4096); + var reasoningLevels = ParseReasoningLevels(element); + var outputCapabilities = ParseOutputCapabilities(element); + if (reasoningLevels.Any(level => level != GameReasoningLevel.Off)) + { + outputCapabilities |= GameModelOutputCapabilities.Reasoning; + } + + return new GameModelDescriptor( + provider.ProviderId, + RequiredString(element, "id", 1024), + OptionalString(element, "name", 4096), + OptionalInt32(element, "contextWindow") ?? 0, + OptionalInt32(element, "maximumOutput") ?? 0, + ParseInputCapabilities(element), + outputCapabilities, + reasoningLevels, + ParseCost(element), + ParseStringMap(element, "metadata"), + ParseReasoningValues(element, reasoningLevels), + OptionalString(element, "api", 256) ?? "custom", + modelBaseUrl is null ? provider.Endpoint : new Uri(modelBaseUrl, UriKind.Absolute), + OptionalObjectJson(element, "sampling"), + ParseHeaderMap(element, "headers"), + OptionalObjectJson(element, "compatibility")); + } + + private static GameModelInputCapabilities ParseInputCapabilities(JsonElement element) + { + var result = GameModelInputCapabilities.None; + foreach (var value in ParseStringArray(element, "input")) + { + result |= value switch + { + "text" => GameModelInputCapabilities.Text, + "image" => GameModelInputCapabilities.Image, + "audio" => GameModelInputCapabilities.Audio, + "video" => GameModelInputCapabilities.Video, + "structured" => GameModelInputCapabilities.StructuredData, + _ => throw new ArgumentException($"Unknown model input capability '{value}'."), + }; + } + + return result; + } + + private static GameModelOutputCapabilities ParseOutputCapabilities(JsonElement element) + { + var result = GameModelOutputCapabilities.None; + foreach (var value in ParseStringArray(element, "output")) + { + result |= value switch + { + "text" => GameModelOutputCapabilities.Text, + "image" => GameModelOutputCapabilities.Image, + "audio" => GameModelOutputCapabilities.Audio, + "video" => GameModelOutputCapabilities.Video, + "structured" => GameModelOutputCapabilities.StructuredData, + "tools" => GameModelOutputCapabilities.ToolCalls, + "reasoning" => GameModelOutputCapabilities.Reasoning, + _ => throw new ArgumentException($"Unknown model output capability '{value}'."), + }; + } + + return result; + } + + private static IReadOnlyList ParseReasoningLevels(JsonElement element) + { + var values = ParseStringArray(element, "reasoning"); + return values.Select(value => value switch + { + "off" => GameReasoningLevel.Off, + "minimal" => GameReasoningLevel.Minimal, + "low" => GameReasoningLevel.Low, + "medium" => GameReasoningLevel.Medium, + "high" => GameReasoningLevel.High, + "xhigh" => GameReasoningLevel.ExtraHigh, + "max" => GameReasoningLevel.Maximum, + _ => throw new ArgumentException($"Unknown reasoning level '{value}'."), + }).ToArray(); + } + + private static IReadOnlyDictionary ParseReasoningValues( + JsonElement element, + IReadOnlyCollection levels) + { + if (!element.TryGetProperty("reasoningValues", out var values)) + { + return new Dictionary(); + } + + RequireKind(values, JsonValueKind.Object, "reasoningValues"); + var result = new Dictionary(); + foreach (var property in values.EnumerateObject()) + { + var level = property.Name switch + { + "off" => GameReasoningLevel.Off, + "minimal" => GameReasoningLevel.Minimal, + "low" => GameReasoningLevel.Low, + "medium" => GameReasoningLevel.Medium, + "high" => GameReasoningLevel.High, + "xhigh" => GameReasoningLevel.ExtraHigh, + "max" => GameReasoningLevel.Maximum, + _ => throw new ArgumentException($"Unknown reasoning-value level '{property.Name}'."), + }; + if (!levels.Contains(level) || property.Value.ValueKind != JsonValueKind.String) + { + throw new ArgumentException("A reasoning value targets an unsupported level."); + } + + result[level] = property.Value.GetString()!; + } + + return new ReadOnlyDictionary(result); + } + + private static GameModelCost ParseCost(JsonElement element) + { + if (!element.TryGetProperty("cost", out var cost)) + { + return new GameModelCost(); + } + + RequireKind(cost, JsonValueKind.Object, "cost"); + var tiers = new List(); + if (cost.TryGetProperty("tiers", out var tiersElement)) + { + RequireKind(tiersElement, JsonValueKind.Array, "tiers"); + foreach (var tier in tiersElement.EnumerateArray()) + { + tiers.Add(new GameModelCostTier( + RequiredInt64(tier, "above"), + OptionalDecimal(tier, "input") ?? 0, + OptionalDecimal(tier, "output") ?? 0, + OptionalDecimal(tier, "cacheRead") ?? 0, + OptionalDecimal(tier, "cacheWrite") ?? 0)); + } + } + + return new GameModelCost( + OptionalDecimal(cost, "input") ?? 0, + OptionalDecimal(cost, "output") ?? 0, + OptionalDecimal(cost, "cacheRead") ?? 0, + OptionalDecimal(cost, "cacheWrite") ?? 0, + tiers); + } + + private static IReadOnlyDictionary ParseStringMap(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var map)) + { + return new Dictionary(); + } + + RequireKind(map, JsonValueKind.Object, propertyName); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in map.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + { + throw new ArgumentException($"'{propertyName}' values must be strings."); + } + + result.Add(property.Name, property.Value.GetString()!); + } + + return new ReadOnlyDictionary(result); + } + + private static IReadOnlyDictionary ParseHeaderMap(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var map)) + { + return new Dictionary(); + } + + RequireKind(map, JsonValueKind.Object, propertyName); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in map.EnumerateObject()) + { + if (property.Value.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + { + throw new ArgumentException($"'{propertyName}' values must be strings or null."); + } + + result.Add( + property.Name, + property.Value.ValueKind == JsonValueKind.Null ? null : property.Value.GetString()); + } + + return new ReadOnlyDictionary(result); + } + + private static IReadOnlyList ParseStringArray(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var array)) + { + return Array.Empty(); + } + + RequireKind(array, JsonValueKind.Array, propertyName); + var values = new List(); + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + throw new ArgumentException($"'{propertyName}' entries must be strings."); + } + + values.Add(item.GetString()!); + } + + return values; + } + + private static string? OptionalObjectJson(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return null; + } + + RequireKind(value, JsonValueKind.Object, propertyName); + return value.GetRawText(); + } + + private static string RequiredString(JsonElement element, string name, int maximum) => + OptionalString(element, name, maximum) + ?? throw new ArgumentException($"The model directory omitted '{name}'."); + + private static string? OptionalString(JsonElement element, string name, int maximum) + { + if (!element.TryGetProperty(name, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) + { + throw new ArgumentException($"The model directory field '{name}' must be a string."); + } + + var result = value.GetString(); + return string.IsNullOrWhiteSpace(result) || result.Length > maximum + ? throw new ArgumentException($"The model directory field '{name}' is invalid.") + : result; + } + + private static bool? OptionalBoolean(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => throw new ArgumentException($"The model directory field '{name}' must be a boolean."), + }; + } + + private static int? OptionalInt32(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var result) && result >= 0 + ? result + : throw new ArgumentException($"The model directory field '{name}' must be a non-negative integer."); + } + + private static long RequiredInt64(JsonElement element, string name) => + element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var result) + && result >= 0 + ? result + : throw new ArgumentException($"The model directory field '{name}' must be a non-negative integer."); + + private static decimal? OptionalDecimal(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var result) && result >= 0 + ? result + : throw new ArgumentException($"The model directory field '{name}' must be a non-negative number."); + } + + private static void RequireKind(JsonElement element, JsonValueKind kind, string name) + { + if (element.ValueKind != kind) + { + throw new ArgumentException($"The model directory field '{name}' must be {kind}."); + } + } +} diff --git a/src/OpenGameAgent.Models/ModelDescriptors.cs b/src/OpenGameAgent.Models/ModelDescriptors.cs index c81dfd2..3baf677 100644 --- a/src/OpenGameAgent.Models/ModelDescriptors.cs +++ b/src/OpenGameAgent.Models/ModelDescriptors.cs @@ -46,12 +46,23 @@ public GameModelCost( decimal inputPerMillionTokens = 0, decimal outputPerMillionTokens = 0, decimal cacheReadPerMillionTokens = 0, - decimal cacheWritePerMillionTokens = 0) + decimal cacheWritePerMillionTokens = 0, + IReadOnlyCollection? tiers = null) { InputPerMillionTokens = RequireCost(inputPerMillionTokens, nameof(inputPerMillionTokens)); OutputPerMillionTokens = RequireCost(outputPerMillionTokens, nameof(outputPerMillionTokens)); CacheReadPerMillionTokens = RequireCost(cacheReadPerMillionTokens, nameof(cacheReadPerMillionTokens)); CacheWritePerMillionTokens = RequireCost(cacheWritePerMillionTokens, nameof(cacheWritePerMillionTokens)); + var copiedTiers = (tiers ?? Array.Empty()) + .OrderBy(tier => tier.InputTokensAbove) + .ToArray(); + if (copiedTiers.Any(tier => tier is null) + || copiedTiers.Select(tier => tier.InputTokensAbove).Distinct().Count() != copiedTiers.Length) + { + throw new ArgumentException("Cost tiers must be non-null and use unique thresholds.", nameof(tiers)); + } + + Tiers = Array.AsReadOnly(copiedTiers); } public decimal InputPerMillionTokens { get; } @@ -62,12 +73,68 @@ public GameModelCost( public decimal CacheWritePerMillionTokens { get; } + public IReadOnlyList Tiers { get; } + + public GameModelCost RatesForInput(long inputTokens) + { + if (inputTokens < 0) + { + throw new ArgumentOutOfRangeException(nameof(inputTokens)); + } + + var tier = Tiers.LastOrDefault(candidate => inputTokens > candidate.InputTokensAbove); + return tier is null + ? this + : new GameModelCost( + tier.InputPerMillionTokens, + tier.OutputPerMillionTokens, + tier.CacheReadPerMillionTokens, + tier.CacheWritePerMillionTokens); + } + private static decimal RequireCost(decimal value, string parameterName) => value is >= 0 and <= 1_000_000 ? value : throw new ArgumentOutOfRangeException(parameterName); } +public sealed class GameModelCostTier +{ + public GameModelCostTier( + long inputTokensAbove, + decimal inputPerMillionTokens, + decimal outputPerMillionTokens, + decimal cacheReadPerMillionTokens = 0, + decimal cacheWritePerMillionTokens = 0) + { + if (inputTokensAbove < 0) + { + throw new ArgumentOutOfRangeException(nameof(inputTokensAbove)); + } + + InputTokensAbove = inputTokensAbove; + var rates = new GameModelCost( + inputPerMillionTokens, + outputPerMillionTokens, + cacheReadPerMillionTokens, + cacheWritePerMillionTokens); + InputPerMillionTokens = rates.InputPerMillionTokens; + OutputPerMillionTokens = rates.OutputPerMillionTokens; + CacheReadPerMillionTokens = rates.CacheReadPerMillionTokens; + CacheWritePerMillionTokens = rates.CacheWritePerMillionTokens; + } + + public long InputTokensAbove { get; } + + public decimal InputPerMillionTokens { get; } + + public decimal OutputPerMillionTokens { get; } + + public decimal CacheReadPerMillionTokens { get; } + + public decimal CacheWritePerMillionTokens { get; } +} + public sealed class GameModelDescriptor { private static readonly GameReasoningLevel[] ReasoningOrder = @@ -92,11 +159,27 @@ public GameModelDescriptor( IReadOnlyCollection? reasoningLevels = null, GameModelCost? cost = null, IReadOnlyDictionary? metadata = null, - IReadOnlyDictionary? reasoningLevelValues = null) + IReadOnlyDictionary? reasoningLevelValues = null, + string api = "custom", + Uri? baseUrl = null, + string? samplingParametersJson = null, + IReadOnlyDictionary? headers = null, + string? compatibilityJson = null) { ProviderId = RequireId(providerId, nameof(providerId)); ModelId = RequireId(modelId, nameof(modelId)); DisplayName = displayName is null ? ModelId : RequireId(displayName, nameof(displayName)); + Api = RequireId(api, nameof(api)); + if (baseUrl is not null + && (!baseUrl.IsAbsoluteUri + || baseUrl.UserInfo.Length > 0 + || baseUrl.Fragment.Length > 0 + || (baseUrl.Scheme != Uri.UriSchemeHttp && baseUrl.Scheme != Uri.UriSchemeHttps))) + { + throw new ArgumentException("A model base URL must be an absolute HTTP or HTTPS URL without embedded credentials or a fragment.", nameof(baseUrl)); + } + + BaseUrl = baseUrl; if (contextWindowTokens < 0 || maximumOutputTokens < 0) { throw new ArgumentOutOfRangeException(nameof(contextWindowTokens)); @@ -122,10 +205,6 @@ public GameModelDescriptor( { levels = new[] { GameReasoningLevel.Off }; } - else if (!levels.Contains(GameReasoningLevel.Off)) - { - levels = new[] { GameReasoningLevel.Off }.Concat(levels).ToArray(); - } if (levels.Any(level => level != GameReasoningLevel.Off) && !outputCapabilities.HasFlag(GameModelOutputCapabilities.Reasoning)) @@ -142,12 +221,11 @@ public GameModelDescriptor( foreach (var pair in reasoningLevelValues ?? new Dictionary()) { if (!Enum.IsDefined(typeof(GameReasoningLevel), pair.Key) - || pair.Key == GameReasoningLevel.Off || !levels.Contains(pair.Key) || string.IsNullOrWhiteSpace(pair.Value) || pair.Value.Length > 128) { - throw new ArgumentException("A reasoning-level value must target a supported non-off level and contain at most 128 characters.", nameof(reasoningLevelValues)); + throw new ArgumentException("A reasoning-level value must target a supported level and contain at most 128 characters.", nameof(reasoningLevelValues)); } values.Add(pair.Key, pair.Value); @@ -156,6 +234,13 @@ public GameModelDescriptor( ReasoningLevelValues = new ReadOnlyDictionary(values); Cost = cost ?? new GameModelCost(); Metadata = CopyMetadata(metadata); + SamplingParametersJson = samplingParametersJson is null + ? null + : RequireObjectJson(samplingParametersJson, nameof(samplingParametersJson)); + Headers = CopyHeaders(headers); + CompatibilityJson = compatibilityJson is null + ? null + : RequireObjectJson(compatibilityJson, nameof(compatibilityJson)); } public string ProviderId { get; } @@ -164,6 +249,10 @@ public GameModelDescriptor( public string DisplayName { get; } + public string Api { get; } + + public Uri? BaseUrl { get; } + public int ContextWindowTokens { get; } public int MaximumOutputTokens { get; } @@ -180,6 +269,12 @@ public GameModelDescriptor( public IReadOnlyDictionary Metadata { get; } + public string? SamplingParametersJson { get; } + + public IReadOnlyDictionary Headers { get; } + + public string? CompatibilityJson { get; } + public GameReasoningLevel ClampReasoning(GameReasoningLevel requested) { if (!Enum.IsDefined(typeof(GameReasoningLevel), requested)) @@ -209,7 +304,7 @@ public GameReasoningLevel ClampReasoning(GameReasoningLevel requested) } } - return GameReasoningLevel.Off; + throw new InvalidOperationException("The model does not expose any reasoning level."); } public bool Supports( @@ -229,11 +324,6 @@ public bool Supports( throw new ArgumentOutOfRangeException(nameof(level)); } - if (level == GameReasoningLevel.Off) - { - return null; - } - if (!ReasoningLevels.Contains(level)) { throw new InvalidOperationException($"Reasoning level '{level}' is not supported by this model."); @@ -246,6 +336,7 @@ public bool Supports( return level switch { + GameReasoningLevel.Off => null, GameReasoningLevel.Minimal => "minimal", GameReasoningLevel.Low => "low", GameReasoningLevel.Medium => "medium", @@ -286,6 +377,75 @@ private static IReadOnlyDictionary CopyMetadata(IReadOnlyDiction return new ReadOnlyDictionary(copy); } + private static IReadOnlyDictionary CopyHeaders( + IReadOnlyDictionary? headers) + { + if (headers is { Count: > 64 }) + { + throw new ArgumentException("Model headers cannot contain more than 64 entries.", nameof(headers)); + } + + var copy = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in headers ?? new Dictionary()) + { + if (!IsHeaderName(pair.Key) + || pair.Value is { Length: > 16_384 } + || pair.Value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || !copy.TryAdd(pair.Key, pair.Value)) + { + throw new ArgumentException( + "Model headers contain an invalid or case-insensitively duplicate entry.", + nameof(headers)); + } + } + + return new ReadOnlyDictionary(copy); + } + + private static bool IsHeaderName(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 256) + { + return false; + } + + foreach (var character in value) + { + if (!(character is >= 'a' and <= 'z' + || character is >= 'A' and <= 'Z' + || character is >= '0' and <= '9' + || character is '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~')) + { + return false; + } + } + + return true; + } + + private static string RequireObjectJson(string value, string parameterName) + { + if (value.Length > 1_000_000) + { + throw new ArgumentException("Model JSON metadata is too large.", parameterName); + } + + try + { + using var document = System.Text.Json.JsonDocument.Parse(value); + if (document.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) + { + throw new ArgumentException("Model JSON metadata must be an object.", parameterName); + } + + return document.RootElement.GetRawText(); + } + catch (System.Text.Json.JsonException exception) + { + throw new ArgumentException("Model JSON metadata must contain valid JSON.", parameterName, exception); + } + } + internal static void ValidateFlags(T value, string parameterName) where T : struct, Enum { diff --git a/src/OpenGameAgent.Models/OAuthFlows.cs b/src/OpenGameAgent.Models/OAuthFlows.cs new file mode 100644 index 0000000..7d023c5 --- /dev/null +++ b/src/OpenGameAgent.Models/OAuthFlows.cs @@ -0,0 +1,738 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Models; + +public sealed class GameOAuthAuthorizationCodeOptions +{ + public GameOAuthAuthorizationCodeOptions( + HttpClient httpClient, + Uri authorizationEndpoint, + Uri tokenEndpoint, + string clientId, + Uri redirectUri) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + AuthorizationEndpoint = RequireEndpoint(authorizationEndpoint, nameof(authorizationEndpoint)); + TokenEndpoint = RequireEndpoint(tokenEndpoint, nameof(tokenEndpoint)); + ClientId = RequireValue(clientId, nameof(clientId)); + RedirectUri = RequireEndpoint(redirectUri, nameof(redirectUri), allowLoopbackHttp: true); + } + + public HttpClient HttpClient { get; } + + public Uri AuthorizationEndpoint { get; } + + public Uri TokenEndpoint { get; } + + public string ClientId { get; } + + public Uri RedirectUri { get; } + + public IList Scopes { get; } = new List(); + + public IDictionary AuthorizationParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + public IDictionary TokenParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + internal static Uri RequireEndpoint(Uri value, string parameterName, bool allowLoopbackHttp = false) + { + if (value is null + || !value.IsAbsoluteUri + || value.UserInfo.Length > 0 + || (value.Scheme != Uri.UriSchemeHttps + && !(allowLoopbackHttp && value.Scheme == Uri.UriSchemeHttp && value.IsLoopback))) + { + throw new ArgumentException("An absolute HTTPS endpoint without embedded credentials is required.", parameterName); + } + + return value; + } + + internal static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 4096 + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A bounded non-empty OAuth value is required.", parameterName); + } + + return value; + } +} + +public sealed class GameOAuthDeviceCodeOptions +{ + public GameOAuthDeviceCodeOptions( + HttpClient httpClient, + Uri deviceAuthorizationEndpoint, + Uri tokenEndpoint, + string clientId) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + DeviceAuthorizationEndpoint = GameOAuthAuthorizationCodeOptions.RequireEndpoint( + deviceAuthorizationEndpoint, + nameof(deviceAuthorizationEndpoint)); + TokenEndpoint = GameOAuthAuthorizationCodeOptions.RequireEndpoint(tokenEndpoint, nameof(tokenEndpoint)); + ClientId = GameOAuthAuthorizationCodeOptions.RequireValue(clientId, nameof(clientId)); + } + + public HttpClient HttpClient { get; } + + public Uri DeviceAuthorizationEndpoint { get; } + + public Uri TokenEndpoint { get; } + + public string ClientId { get; } + + public IList Scopes { get; } = new List(); + + public IDictionary DeviceParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + public IDictionary TokenParameters { get; } = + new Dictionary(StringComparer.Ordinal); + + public Func DelayAsync { get; set; } = Task.Delay; +} + +public static class GameOAuth +{ + private const int MaximumResponseBytes = 1_000_000; + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + private static readonly TimeSpan MaximumLifetime = TimeSpan.FromDays(365); + + public static StoredGameProviderAuthentication CreateAuthorizationCodeAuthentication( + string providerId, + IGameCredentialStore store, + GameOAuthAuthorizationCodeOptions options, + string profile = "default", + Func? clock = null, + TimeSpan? refreshSkew = null) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return new StoredGameProviderAuthentication( + providerId, + store, + new[] { "oauth-authorization-code" }, + (_, interaction, cancellationToken) => + LoginAuthorizationCodeAsync(options, interaction, cancellationToken), + (credential, cancellationToken) => RefreshAsync( + options.HttpClient, + options.TokenEndpoint, + options.ClientId, + credential, + new Dictionary(options.TokenParameters, StringComparer.Ordinal), + cancellationToken), + profile, + clock, + refreshSkew); + } + + public static StoredGameProviderAuthentication CreateDeviceCodeAuthentication( + string providerId, + IGameCredentialStore store, + GameOAuthDeviceCodeOptions options, + string profile = "default", + Func? clock = null, + TimeSpan? refreshSkew = null) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return new StoredGameProviderAuthentication( + providerId, + store, + new[] { "oauth-device-code" }, + (_, interaction, cancellationToken) => + LoginDeviceCodeAsync(options, interaction, cancellationToken), + (credential, cancellationToken) => RefreshAsync( + options.HttpClient, + options.TokenEndpoint, + options.ClientId, + credential, + new Dictionary(options.TokenParameters, StringComparer.Ordinal), + cancellationToken), + profile, + clock, + refreshSkew); + } + + public static async ValueTask LoginAuthorizationCodeAsync( + GameOAuthAuthorizationCodeOptions options, + GameAuthInteraction interaction, + CancellationToken cancellationToken = default) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + ValidateCollections(options.Scopes, options.AuthorizationParameters, options.TokenParameters); + var verifier = RandomUrlToken(64); + var state = RandomUrlToken(32); + var challenge = Base64Url(Sha256(Encoding.ASCII.GetBytes(verifier))); + var authorizationFields = new Dictionary(StringComparer.Ordinal) + { + ["response_type"] = "code", + ["client_id"] = options.ClientId, + ["redirect_uri"] = options.RedirectUri.AbsoluteUri, + ["state"] = state, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + }; + var scopes = NormalizeScopes(options.Scopes); + if (scopes.Count > 0) + { + authorizationFields["scope"] = string.Join(" ", scopes); + } + + var authorization = BuildUri( + options.AuthorizationEndpoint, + Merge(options.AuthorizationParameters, authorizationFields)); + if (interaction.OpenBrowserAsync is not null) + { + await interaction.OpenBrowserAsync(authorization, cancellationToken).ConfigureAwait(false); + } + else if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync(authorization.AbsoluteUri, cancellationToken).ConfigureAwait(false); + } + + var prompt = interaction.PromptAsync + ?? throw new InvalidOperationException("The OAuth interaction must accept the authorization code or callback URL."); + var response = await prompt( + "Paste the OAuth callback URL or authorization code.", + true, + cancellationToken).ConfigureAwait(false); + var code = ParseAuthorizationResponse(response, state); + var fields = Merge( + options.TokenParameters, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["client_id"] = options.ClientId, + ["code"] = code, + ["redirect_uri"] = options.RedirectUri.AbsoluteUri, + ["code_verifier"] = verifier, + }); + return await ExchangeAsync(options.HttpClient, options.TokenEndpoint, fields, null, cancellationToken) + .ConfigureAwait(false); + } + + public static async ValueTask LoginDeviceCodeAsync( + GameOAuthDeviceCodeOptions options, + GameAuthInteraction interaction, + CancellationToken cancellationToken = default) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + ValidateCollections(options.Scopes, options.DeviceParameters, options.TokenParameters); + if (options.DelayAsync is null) + { + throw new ArgumentException("A device-code delay strategy is required.", nameof(options)); + } + + var requiredDeviceFields = new Dictionary(StringComparer.Ordinal) + { + ["client_id"] = options.ClientId, + }; + var deviceScopes = NormalizeScopes(options.Scopes); + if (deviceScopes.Count > 0) + { + requiredDeviceFields["scope"] = string.Join(" ", deviceScopes); + } + + var requestFields = Merge(options.DeviceParameters, requiredDeviceFields); + var deviceResponse = await PostFormAsync( + options.HttpClient, + options.DeviceAuthorizationEndpoint, + requestFields, + cancellationToken).ConfigureAwait(false); + using var document = ParseObject(deviceResponse.Body, "The device authorization response is invalid."); + if (!deviceResponse.Success) + { + throw OAuthFailure("Device authorization failed", document.RootElement, deviceResponse.StatusCode); + } + + var root = document.RootElement; + var deviceCode = RequiredString(root, "device_code", 65_536); + var userCode = RequiredString(root, "user_code", 4096); + var verification = OptionalString(root, "verification_uri_complete") + ?? OptionalString(root, "verification_uri") + ?? throw new InvalidOperationException("The device authorization response omitted its verification URI."); + var verificationUri = GameOAuthAuthorizationCodeOptions.RequireEndpoint( + new Uri(verification, UriKind.Absolute), + "verification_uri"); + var expiresIn = ReadSeconds(root, "expires_in", TimeSpan.FromMinutes(15)); + var interval = ReadSeconds(root, "interval", TimeSpan.FromSeconds(5)); + if (interval < TimeSpan.Zero || interval > TimeSpan.FromMinutes(5)) + { + throw new InvalidOperationException("The device authorization polling interval is invalid."); + } + + if (interaction.NotifyAsync is not null) + { + await interaction.NotifyAsync($"Enter device code {userCode} at {verificationUri}", cancellationToken) + .ConfigureAwait(false); + } + + if (interaction.OpenBrowserAsync is not null) + { + await interaction.OpenBrowserAsync(verificationUri, cancellationToken).ConfigureAwait(false); + } + + var deadline = DateTimeOffset.UtcNow + expiresIn; + var currentInterval = interval; + while (DateTimeOffset.UtcNow < deadline) + { + await options.DelayAsync(currentInterval, cancellationToken).ConfigureAwait(false); + var fields = Merge( + options.TokenParameters, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", + ["client_id"] = options.ClientId, + ["device_code"] = deviceCode, + }); + var tokenResponse = await PostFormAsync( + options.HttpClient, + options.TokenEndpoint, + fields, + cancellationToken).ConfigureAwait(false); + using var tokenDocument = ParseObject(tokenResponse.Body, "The device token response is invalid."); + if (tokenResponse.Success) + { + return ParseCredential(tokenDocument.RootElement, null); + } + + var error = OptionalString(tokenDocument.RootElement, "error") ?? string.Empty; + if (string.Equals(error, "authorization_pending", StringComparison.Ordinal)) + { + continue; + } + + if (string.Equals(error, "slow_down", StringComparison.Ordinal)) + { + currentInterval = currentInterval + TimeSpan.FromSeconds(5); + if (currentInterval > TimeSpan.FromMinutes(5)) + { + currentInterval = TimeSpan.FromMinutes(5); + } + + continue; + } + + throw OAuthFailure("Device authorization failed", tokenDocument.RootElement, tokenResponse.StatusCode); + } + + throw new TimeoutException("The device authorization code expired before login completed."); + } + + public static ValueTask RefreshAsync( + HttpClient httpClient, + Uri tokenEndpoint, + string clientId, + GameCredential credential, + IReadOnlyDictionary? tokenParameters = null, + CancellationToken cancellationToken = default) + { + if (httpClient is null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + + GameOAuthAuthorizationCodeOptions.RequireEndpoint(tokenEndpoint, nameof(tokenEndpoint)); + GameOAuthAuthorizationCodeOptions.RequireValue(clientId, nameof(clientId)); + if (credential is null || credential.Kind != GameCredentialKind.OAuth) + { + throw new ArgumentException("An OAuth credential is required.", nameof(credential)); + } + + if (!credential.Metadata.TryGetValue("refresh_token", out var refreshToken) + || string.IsNullOrWhiteSpace(refreshToken)) + { + throw new InvalidOperationException("The OAuth credential does not contain a refresh token."); + } + + var fields = Merge( + tokenParameters ?? new Dictionary(), + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["client_id"] = clientId, + ["refresh_token"] = refreshToken, + }); + return ExchangeAsync(httpClient, tokenEndpoint, fields, refreshToken, cancellationToken); + } + + private static async ValueTask ExchangeAsync( + HttpClient client, + Uri endpoint, + IReadOnlyDictionary fields, + string? previousRefreshToken, + CancellationToken cancellationToken) + { + var response = await PostFormAsync(client, endpoint, fields, cancellationToken).ConfigureAwait(false); + using var document = ParseObject(response.Body, "The OAuth token response is invalid."); + if (!response.Success) + { + throw OAuthFailure("OAuth token exchange failed", document.RootElement, response.StatusCode); + } + + return ParseCredential(document.RootElement, previousRefreshToken); + } + + private static GameCredential ParseCredential(JsonElement root, string? previousRefreshToken) + { + var accessToken = RequiredString(root, "access_token", 65_536); + var refreshToken = OptionalString(root, "refresh_token") ?? previousRefreshToken; + var tokenType = OptionalString(root, "token_type"); + var scope = OptionalString(root, "scope"); + DateTimeOffset? expiresAt = null; + if (root.TryGetProperty("expires_in", out var expiresElement)) + { + var lifetime = ReadSeconds(root, "expires_in", TimeSpan.Zero); + if (lifetime <= TimeSpan.Zero || lifetime > MaximumLifetime) + { + throw new InvalidOperationException("The OAuth token lifetime is invalid."); + } + + expiresAt = DateTimeOffset.UtcNow + lifetime; + } + + var metadata = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(refreshToken)) + { + metadata["refresh_token"] = Bound(refreshToken!, 65_536, "refresh_token"); + } + + if (!string.IsNullOrWhiteSpace(tokenType)) + { + metadata["token_type"] = Bound(tokenType!, 256, "token_type"); + } + + if (!string.IsNullOrWhiteSpace(scope)) + { + metadata["scope"] = Bound(scope!, 16_384, "scope"); + } + + return new GameCredential(GameCredentialKind.OAuth, accessToken, expiresAt, metadata); + } + + private static string ParseAuthorizationResponse(string value, string expectedState) + { + var input = GameOAuthAuthorizationCodeOptions.RequireValue(value?.Trim() ?? string.Empty, nameof(value)); + if (!Uri.TryCreate(input, UriKind.Absolute, out var callback)) + { + return input; + } + + var query = ParseQuery(callback.Query); + if (query.TryGetValue("error", out var error)) + { + throw new InvalidOperationException("OAuth authorization failed: " + Bound(error, 4096, "error")); + } + + if (!query.TryGetValue("state", out var state) + || !FixedTimeEquals(state, expectedState)) + { + throw new InvalidOperationException("The OAuth callback state did not match the active login request."); + } + + return query.TryGetValue("code", out var code) + ? GameOAuthAuthorizationCodeOptions.RequireValue(code, "code") + : throw new InvalidOperationException("The OAuth callback omitted the authorization code."); + } + + private static async ValueTask PostFormAsync( + HttpClient client, + Uri endpoint, + IReadOnlyDictionary fields, + CancellationToken cancellationToken) + { + ValidateParameters(fields); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = new FormUrlEncodedContent(fields), + }; + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + var body = await ReadBoundedResponseAsync(response.Content, cancellationToken).ConfigureAwait(false); + + return new FormResponse(response.IsSuccessStatusCode, response.StatusCode, body); + } + + private static async ValueTask ReadBoundedResponseAsync( + HttpContent content, + CancellationToken cancellationToken) + { + var stream = await CancellableOperation.WaitAsync( + new ValueTask(content.ReadAsStreamAsync()), + cancellationToken).ConfigureAwait(false); + using (stream) + using (var buffer = new MemoryStream()) + { + var chunk = new byte[8192]; + while (true) + { + var read = await CancellableOperation.WaitAsync( + new ValueTask(stream.ReadAsync(chunk, 0, chunk.Length, cancellationToken)), + cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > MaximumResponseBytes) + { + throw new InvalidOperationException("The OAuth response exceeded the configured safety bound."); + } + + buffer.Write(chunk, 0, read); + } + + try + { + return StrictUtf8.GetString(buffer.ToArray()); + } + catch (DecoderFallbackException exception) + { + throw new InvalidOperationException("The OAuth response is not valid UTF-8.", exception); + } + } + } + + private static JsonDocument ParseObject(string body, string error) + { + try + { + var document = JsonDocument.Parse(body, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + document.Dispose(); + throw new InvalidOperationException(error); + } + + return document; + } + catch (JsonException exception) + { + throw new InvalidOperationException(error, exception); + } + } + + private static Exception OAuthFailure(string prefix, JsonElement root, HttpStatusCode statusCode) + { + var code = OptionalString(root, "error"); + var description = OptionalString(root, "error_description") ?? OptionalString(root, "message"); + var details = string.Join(": ", new[] { code, description }.Where(value => !string.IsNullOrWhiteSpace(value))); + return new InvalidOperationException( + $"{prefix} with HTTP {(int)statusCode}{(details.Length == 0 ? string.Empty : ": " + Bound(details, 8192, "error"))}."); + } + + private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary parameters) + { + var query = ParseQuery(endpoint.Query); + foreach (var pair in parameters) + { + query[pair.Key] = pair.Value; + } + + var builder = new UriBuilder(endpoint) + { + Query = string.Join("&", query.Select(pair => + Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))), + }; + return builder.Uri; + } + + private static Dictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var part in query.TrimStart('?').Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + var pieces = part.Split(new[] { '=' }, 2); + result[Uri.UnescapeDataString(pieces[0])] = pieces.Length == 2 + ? Uri.UnescapeDataString(pieces[1]) + : string.Empty; + } + + return result; + } + + private static IReadOnlyDictionary Merge( + IEnumerable> configured, + IReadOnlyDictionary required) + { + var configuredValues = configured.ToArray(); + ValidateParameters(configuredValues); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in configuredValues) + { + result[pair.Key] = pair.Value; + } + foreach (var pair in required) + { + result[pair.Key] = pair.Value; + } + + return result; + } + + private static void ValidateCollections( + IEnumerable scopes, + IEnumerable> first, + IEnumerable> second) + { + _ = NormalizeScopes(scopes); + ValidateParameters(first); + ValidateParameters(second); + } + + private static IReadOnlyList NormalizeScopes(IEnumerable scopes) + { + var result = scopes + .Select(scope => GameOAuthAuthorizationCodeOptions.RequireValue(scope, nameof(scopes))) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (result.Length > 256) + { + throw new ArgumentException("At most 256 OAuth scopes are supported.", nameof(scopes)); + } + + return result; + } + + private static void ValidateParameters(IEnumerable> parameters) + { + var values = parameters.ToArray(); + if (values.Length > 256) + { + throw new ArgumentException("At most 256 OAuth parameters are supported.", nameof(parameters)); + } + + foreach (var pair in values) + { + GameOAuthAuthorizationCodeOptions.RequireValue(pair.Key, nameof(parameters)); + GameOAuthAuthorizationCodeOptions.RequireValue(pair.Value, nameof(parameters)); + } + } + + private static string RequiredString(JsonElement root, string property, int maximum) + { + var value = OptionalString(root, property); + return string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"The OAuth response omitted '{property}'.") + : Bound(value!, maximum, property); + } + + private static string? OptionalString(JsonElement root, string property) => + root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static TimeSpan ReadSeconds(JsonElement root, string property, TimeSpan fallback) + { + if (!root.TryGetProperty(property, out var value)) + { + return fallback; + } + + double seconds; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out seconds)) + { + } + else if (value.ValueKind == JsonValueKind.String + && double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + } + else + { + throw new InvalidOperationException($"The OAuth response field '{property}' is invalid."); + } + + if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds < 0 || seconds > MaximumLifetime.TotalSeconds) + { + throw new InvalidOperationException($"The OAuth response field '{property}' is outside its allowed range."); + } + + return TimeSpan.FromSeconds(seconds); + } + + private static string Bound(string value, int maximum, string name) => + value.Length <= maximum + ? value + : throw new InvalidOperationException($"The OAuth field '{name}' exceeded its safety bound."); + + private static string RandomUrlToken(int bytes) + { + var data = new byte[bytes]; + using var random = RandomNumberGenerator.Create(); + random.GetBytes(data); + return Base64Url(data); + } + + private static byte[] Sha256(byte[] value) + { + using var hash = SHA256.Create(); + return hash.ComputeHash(value); + } + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + return leftBytes.Length == rightBytes.Length + && CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + private sealed class FormResponse + { + public FormResponse(bool success, HttpStatusCode statusCode, string body) + { + Success = success; + StatusCode = statusCode; + Body = body; + } + + public bool Success { get; } + + public HttpStatusCode StatusCode { get; } + + public string Body { get; } + } +} diff --git a/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj b/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj index 1c00f6d..b637fa4 100644 --- a/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj +++ b/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj @@ -5,6 +5,9 @@ OpenGameAgent.Models - + + + + diff --git a/src/OpenGameAgent.Models/ProviderCatalog.cs b/src/OpenGameAgent.Models/ProviderCatalog.cs index 3611091..1bf5c5b 100644 --- a/src/OpenGameAgent.Models/ProviderCatalog.cs +++ b/src/OpenGameAgent.Models/ProviderCatalog.cs @@ -21,9 +21,15 @@ public GameProviderDescriptor( { ProviderId = GameModelDescriptor.RequireId(providerId, nameof(providerId)); DisplayName = displayName is null ? ProviderId : GameModelDescriptor.RequireId(displayName, nameof(displayName)); - if (endpoint is not null && (!endpoint.IsAbsoluteUri || endpoint.UserInfo.Length > 0)) + if (endpoint is not null + && (!endpoint.IsAbsoluteUri + || endpoint.UserInfo.Length > 0 + || endpoint.Fragment.Length > 0 + || endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps)) { - throw new ArgumentException("A provider endpoint must be absolute and cannot contain user information.", nameof(endpoint)); + throw new ArgumentException( + "A provider endpoint must be an absolute HTTP or HTTPS URL without embedded credentials or a fragment.", + nameof(endpoint)); } Endpoint = endpoint; @@ -102,6 +108,17 @@ public delegate IAsyncEnumerable GameModelStream( GameProviderAuthResolution? authentication, CancellationToken cancellationToken); +public delegate IAsyncEnumerable GameModelDeferredFetch( + DeferredModelHandle handle, + TimeSpan wait, + GameProviderAuthResolution? authentication, + CancellationToken cancellationToken); + +public delegate ValueTask GameModelDeferredCancel( + DeferredModelHandle handle, + GameProviderAuthResolution? authentication, + CancellationToken cancellationToken); + public sealed class GameModelProviderRegistration { public GameModelProviderRegistration( @@ -112,6 +129,8 @@ public GameModelProviderRegistration( GameModelRefresh? refreshModels = null, GameModelAvailabilityFilter? filterModels = null, GameModelStream? stream = null, + GameModelDeferredFetch? fetchDeferred = null, + GameModelDeferredCancel? cancelDeferred = null, string catalogVersion = "1") { Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -126,6 +145,16 @@ public GameModelProviderRegistration( RefreshModels = refreshModels; FilterModels = filterModels; Stream = stream ?? ((request, _, cancellationToken) => provider.StreamAsync(request, cancellationToken)); + FetchDeferred = fetchDeferred + ?? (provider is IDeferredModelProvider deferred + ? (handle, wait, _, cancellationToken) => + deferred.FetchDeferredAsync(handle, wait, cancellationToken) + : null); + CancelDeferred = cancelDeferred + ?? (provider is IDeferredModelProvider deferredCancel + ? (handle, _, cancellationToken) => + deferredCancel.CancelDeferredAsync(handle, cancellationToken) + : null); CatalogVersion = GameModelDescriptor.RequireId(catalogVersion, nameof(catalogVersion)); } @@ -143,6 +172,10 @@ public GameModelProviderRegistration( public GameModelStream Stream { get; } + public GameModelDeferredFetch? FetchDeferred { get; } + + public GameModelDeferredCancel? CancelDeferred { get; } + public string CatalogVersion { get; } internal static IReadOnlyList ValidateModels( @@ -257,10 +290,15 @@ public decimal EstimateCost(ModelUsage usage) } const decimal scale = 1_000_000m; - return usage.InputTokens / scale * Model.Cost.InputPerMillionTokens - + usage.OutputTokens / scale * Model.Cost.OutputPerMillionTokens - + usage.CacheReadTokens / scale * Model.Cost.CacheReadPerMillionTokens - + usage.CacheWriteTokens / scale * Model.Cost.CacheWritePerMillionTokens; + var inputVolume = checked(usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens); + var rates = Model.Cost.RatesForInput(inputVolume); + var longCacheWrite = usage.CacheWriteOneHourTokens ?? 0; + var shortCacheWrite = usage.CacheWriteTokens - longCacheWrite; + return usage.InputTokens / scale * rates.InputPerMillionTokens + + usage.OutputTokens / scale * rates.OutputPerMillionTokens + + usage.CacheReadTokens / scale * rates.CacheReadPerMillionTokens + + shortCacheWrite / scale * rates.CacheWritePerMillionTokens + + longCacheWrite / scale * rates.InputPerMillionTokens * 2; } } @@ -435,35 +473,278 @@ private static async ValueTask> GetAvailableM CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var status = await entry.Registration.Authentication.CheckAsync(cancellationToken).ConfigureAwait(false) + var status = await CancellableOperation.WaitAsync( + entry.Registration.Authentication.CheckAsync(cancellationToken), + cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The provider authentication check returned null."); if (!status.Configured) { return Array.Empty(); } - var auth = await entry.Registration.Authentication.ResolveAsync(cancellationToken).ConfigureAwait(false); + var auth = await CancellableOperation.WaitAsync( + entry.Registration.Authentication.ResolveAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); var models = entry.Models; if (entry.Registration.FilterModels is null) { return models; } - models = await entry.Registration.FilterModels(models, auth, cancellationToken).ConfigureAwait(false) + models = await CancellableOperation.WaitAsync( + entry.Registration.FilterModels(models, auth, cancellationToken), + cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The provider model filter returned null."); return GameModelProviderRegistration.ValidateModels( entry.Registration.Descriptor.ProviderId, models); } - internal IModelProvider CreateDispatchProvider(string providerId) => + public IModelProvider CreateProvider(string providerId) => new CatalogDispatchProvider(this, GameModelDescriptor.RequireId(providerId, nameof(providerId))); + public async ValueTask CheckAuthenticationAsync( + string providerId, + CancellationToken cancellationToken = default) + { + var registration = RequireRegistration(providerId); + try + { + return await CancellableOperation.WaitAsync( + registration.Authentication.CheckAsync(cancellationToken), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Provider '{registration.Descriptor.ProviderId}' returned no authentication status."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Authentication check failed for provider '{registration.Descriptor.ProviderId}'.", + exception); + } + } + + public async ValueTask ResolveAuthenticationAsync( + string providerId, + CancellationToken cancellationToken = default) + { + var registration = RequireRegistration(providerId); + try + { + return await CancellableOperation.WaitAsync( + registration.Authentication.ResolveAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Authentication resolution failed for provider '{registration.Descriptor.ProviderId}'.", + exception); + } + } + + public async ValueTask LoginAsync( + string providerId, + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken = default) + { + var registration = RequireRegistration(providerId); + var requestedScheme = GameModelDescriptor.RequireId(scheme, nameof(scheme)); + if (interaction is null) + { + throw new ArgumentNullException(nameof(interaction)); + } + + try + { + return await CancellableOperation.WaitAsync( + registration.Authentication.LoginAsync( + requestedScheme, + interaction, + cancellationToken), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Provider '{registration.Descriptor.ProviderId}' returned no login credential."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Authentication login failed for provider '{registration.Descriptor.ProviderId}'.", + exception); + } + } + + public async ValueTask LogoutAsync( + string providerId, + CancellationToken cancellationToken = default) + { + var registration = RequireRegistration(providerId); + try + { + await CancellableOperation.WaitAsync( + registration.Authentication.LogoutAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Authentication logout failed for provider '{registration.Descriptor.ProviderId}'.", + exception); + } + } + + public IAsyncEnumerable FetchDeferredAsync( + DeferredModelHandle handle, + TimeSpan wait, + CancellationToken cancellationToken = default) => + FetchDeferredCoreAsync( + handle ?? throw new ArgumentNullException(nameof(handle)), + wait, + cancellationToken); + + public async ValueTask CancelDeferredAsync( + DeferredModelHandle handle, + CancellationToken cancellationToken = default) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + + var registration = RequireDeferredRegistration(handle, requireCancel: true); + var authentication = await ResolveConfiguredAuthenticationAsync(registration, cancellationToken) + .ConfigureAwait(false); + await CancellableOperation.WaitAsync( + registration.CancelDeferred!(handle, authentication, cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + private async IAsyncEnumerable FetchDeferredCoreAsync( + DeferredModelHandle handle, + TimeSpan wait, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var registration = RequireDeferredRegistration(handle, requireCancel: false); + var authentication = await ResolveConfiguredAuthenticationAsync(registration, cancellationToken) + .ConfigureAwait(false); + await foreach (var streamEvent in registration.FetchDeferred!( + handle, + wait, + authentication, + cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return streamEvent + ?? throw new InvalidOperationException("A deferred model provider emitted a null stream event."); + } + } + + private GameModelProviderRegistration RequireRegistration(string providerId) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + lock (_gate) + { + return _providers.TryGetValue(id, out var entry) + ? entry.Registration + : throw new KeyNotFoundException($"Provider '{id}' is not registered."); + } + } + + private GameModelProviderRegistration RequireDeferredRegistration( + DeferredModelHandle handle, + bool requireCancel) + { + lock (_gate) + { + if (!_providers.TryGetValue(handle.Provider, out var entry)) + { + throw new ModelProviderException( + $"Provider '{handle.Provider}' is no longer registered.", + isTransient: false); + } + + var model = entry.CurrentModels.FirstOrDefault(candidate => + string.Equals(candidate.ModelId, handle.Model, StringComparison.Ordinal)); + if (model is null) + { + throw new ModelProviderException( + $"Model '{handle.Provider}/{handle.Model}' is no longer registered.", + isTransient: false); + } + + if (!string.Equals(model.Api, handle.Api, StringComparison.Ordinal)) + { + throw new ModelProviderException( + $"Deferred handle API '{handle.Api}' does not match model API '{model.Api}'.", + isTransient: false); + } + + var supported = requireCancel + ? entry.Registration.CancelDeferred is not null + : entry.Registration.FetchDeferred is not null; + if (!supported) + { + throw new ModelProviderException( + requireCancel + ? $"Provider '{handle.Provider}' cannot cancel deferred responses for API '{handle.Api}'." + : $"Provider '{handle.Provider}' does not support deferred responses for API '{handle.Api}'.", + isTransient: false); + } + + return entry.Registration; + } + } + + private static async ValueTask ResolveAuthenticationStatusAsync( + GameModelProviderRegistration registration, + CancellationToken cancellationToken) => + await CancellableOperation.WaitAsync( + registration.Authentication.CheckAsync(cancellationToken), + cancellationToken).ConfigureAwait(false) + ?? throw new ModelProviderException( + $"Provider '{registration.Descriptor.ProviderId}' returned no authentication status.", + isTransient: false); + + private static async ValueTask ResolveConfiguredAuthenticationAsync( + GameModelProviderRegistration registration, + CancellationToken cancellationToken) + { + var status = await ResolveAuthenticationStatusAsync(registration, cancellationToken).ConfigureAwait(false); + if (!status.Configured) + { + throw new ModelProviderException( + status.Error ?? $"Provider '{registration.Descriptor.ProviderId}' is not configured.", + isTransient: false); + } + + return await CancellableOperation.WaitAsync( + registration.Authentication.ResolveAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + private async IAsyncEnumerable StreamAsync( string providerId, ModelRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); GameModelProviderRegistration registration; lock (_gate) { @@ -484,10 +765,7 @@ private async IAsyncEnumerable StreamAsync( registration = entry.Registration; } - var status = await registration.Authentication.CheckAsync(cancellationToken).ConfigureAwait(false) - ?? throw new ModelProviderException( - $"Provider '{providerId}' returned no authentication status.", - isTransient: false); + var status = await ResolveAuthenticationStatusAsync(registration, cancellationToken).ConfigureAwait(false); if (!status.Configured) { throw new ModelProviderException( @@ -495,7 +773,9 @@ private async IAsyncEnumerable StreamAsync( isTransient: false); } - var authentication = await registration.Authentication.ResolveAsync(cancellationToken).ConfigureAwait(false); + var authentication = await CancellableOperation.WaitAsync( + registration.Authentication.ResolveAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); await foreach (var streamEvent in registration.Stream( request, authentication, @@ -536,7 +816,9 @@ public async ValueTask RefreshAsync( { await snapshot.RefreshGate.WaitAsync(snapshot.RefreshToken).ConfigureAwait(false); refreshGateAcquired = true; - var stored = await _store.LoadAsync(id, snapshot.RefreshToken).ConfigureAwait(false); + var stored = await CancellableOperation.WaitAsync( + _store.LoadAsync(id, snapshot.RefreshToken), + snapshot.RefreshToken).ConfigureAwait(false); var currentModels = snapshot.Models; if (stored is not null && string.Equals( @@ -564,22 +846,28 @@ public async ValueTask RefreshAsync( } } - var status = await snapshot.Registration.Authentication.CheckAsync(snapshot.RefreshToken).ConfigureAwait(false) + var status = await CancellableOperation.WaitAsync( + snapshot.Registration.Authentication.CheckAsync(snapshot.RefreshToken), + snapshot.RefreshToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The provider authentication check returned null."); if (!status.Configured) { return new GameModelRefreshResult(id, GameModelRefreshStatus.SkippedUnconfigured, currentModels.Count); } - var authentication = await snapshot.Registration.Authentication.ResolveAsync(snapshot.RefreshToken).ConfigureAwait(false); - var refreshed = await snapshot.Registration.RefreshModels( - new GameModelRefreshContext( - snapshot.Registration.Descriptor, - currentModels, - authentication, - allowNetwork, - force), - snapshot.RefreshToken).ConfigureAwait(false) + var authentication = await CancellableOperation.WaitAsync( + snapshot.Registration.Authentication.ResolveAsync(snapshot.RefreshToken), + snapshot.RefreshToken).ConfigureAwait(false); + var refreshed = await CancellableOperation.WaitAsync( + snapshot.Registration.RefreshModels( + new GameModelRefreshContext( + snapshot.Registration.Descriptor, + currentModels, + authentication, + allowNetwork, + force), + snapshot.RefreshToken), + snapshot.RefreshToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The provider model refresh returned null."); var dynamicModels = GameModelProviderRegistration.ValidateModels(id, refreshed); lock (_gate) @@ -596,13 +884,15 @@ public async ValueTask RefreshAsync( } } - var save = await _store.SaveAsync( - new GameStoredModelCatalog( - id, - snapshot.Registration.CatalogVersion, - dynamicModels, - _clock()), - stored?.Revision ?? 0, + var save = await CancellableOperation.WaitAsync( + _store.SaveAsync( + new GameStoredModelCatalog( + id, + snapshot.Registration.CatalogVersion, + dynamicModels, + _clock()), + stored?.Revision ?? 0, + snapshot.RefreshToken), snapshot.RefreshToken).ConfigureAwait(false); if (save.Status == GameModelCatalogSaveStatus.Conflict) { @@ -676,6 +966,7 @@ public async ValueTask> RefreshAsync( ids = (providerIds ?? _providers.Keys.ToArray()) .Select(id => GameModelDescriptor.RequireId(id, nameof(providerIds))) .Distinct(StringComparer.Ordinal) + .Where(id => _providers.ContainsKey(id)) .OrderBy(id => id, StringComparer.Ordinal) .ToArray(); } @@ -729,6 +1020,8 @@ private static bool Equivalent(GameModelDescriptor left, GameModelDescriptor rig string.Equals(left.ProviderId, right.ProviderId, StringComparison.Ordinal) && string.Equals(left.ModelId, right.ModelId, StringComparison.Ordinal) && string.Equals(left.DisplayName, right.DisplayName, StringComparison.Ordinal) + && string.Equals(left.Api, right.Api, StringComparison.Ordinal) + && Equals(left.BaseUrl, right.BaseUrl) && left.ContextWindowTokens == right.ContextWindowTokens && left.MaximumOutputTokens == right.MaximumOutputTokens && left.InputCapabilities == right.InputCapabilities @@ -737,13 +1030,33 @@ private static bool Equivalent(GameModelDescriptor left, GameModelDescriptor rig && left.ReasoningLevelValues.Count == right.ReasoningLevelValues.Count && left.ReasoningLevelValues.All(pair => right.ReasoningLevelValues.TryGetValue(pair.Key, out var value) && string.Equals(pair.Value, value, StringComparison.Ordinal)) - && left.Cost.InputPerMillionTokens == right.Cost.InputPerMillionTokens - && left.Cost.OutputPerMillionTokens == right.Cost.OutputPerMillionTokens - && left.Cost.CacheReadPerMillionTokens == right.Cost.CacheReadPerMillionTokens - && left.Cost.CacheWritePerMillionTokens == right.Cost.CacheWritePerMillionTokens - && left.Metadata.Count == right.Metadata.Count - && left.Metadata.All(pair => right.Metadata.TryGetValue(pair.Key, out var value) - && string.Equals(pair.Value, value, StringComparison.Ordinal)); + && Equivalent(left.Cost, right.Cost) + && Equivalent(left.Metadata, right.Metadata) + && string.Equals(left.SamplingParametersJson, right.SamplingParametersJson, StringComparison.Ordinal) + && Equivalent(left.Headers, right.Headers) + && string.Equals(left.CompatibilityJson, right.CompatibilityJson, StringComparison.Ordinal); + + private static bool Equivalent(GameModelCost left, GameModelCost right) => + left.InputPerMillionTokens == right.InputPerMillionTokens + && left.OutputPerMillionTokens == right.OutputPerMillionTokens + && left.CacheReadPerMillionTokens == right.CacheReadPerMillionTokens + && left.CacheWritePerMillionTokens == right.CacheWritePerMillionTokens + && left.Tiers.Count == right.Tiers.Count + && left.Tiers.Zip(right.Tiers, Equivalent).All(equivalent => equivalent); + + private static bool Equivalent(GameModelCostTier left, GameModelCostTier right) => + left.InputTokensAbove == right.InputTokensAbove + && left.InputPerMillionTokens == right.InputPerMillionTokens + && left.OutputPerMillionTokens == right.OutputPerMillionTokens + && left.CacheReadPerMillionTokens == right.CacheReadPerMillionTokens + && left.CacheWritePerMillionTokens == right.CacheWritePerMillionTokens; + + private static bool Equivalent( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && EqualityComparer.Default.Equals(pair.Value, value)); private sealed class CatalogDispatchProvider : IModelProvider { diff --git a/src/OpenGameAgent.Models/packages.lock.json b/src/OpenGameAgent.Models/packages.lock.json index 2d58bb3..1e82da3 100644 --- a/src/OpenGameAgent.Models/packages.lock.json +++ b/src/OpenGameAgent.Models/packages.lock.json @@ -63,13 +63,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, - "opengameagent": { - "type": "Project", - "dependencies": { - "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", - "System.Text.Json": "[8.0.6, )" - } - }, "opengameagent.kernel": { "type": "Project", "dependencies": { diff --git a/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs b/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs index 289cf53..09e556c 100644 --- a/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs +++ b/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Text.Json; @@ -19,13 +20,48 @@ public sealed class DirectoryGameSkillSource : IGameSkillSource private readonly int _maximumManifestCharacters; private readonly int _maximumInstructionsCharacters; private readonly int _maximumScannedDirectories; + private readonly int _maximumIgnoreCharacters; + private readonly int _maximumDiagnostics; + private readonly int _maximumDiagnosticCharacters; + private readonly bool _continueOnError; + private readonly bool _honorIgnoreFiles; + private readonly string _source; + private readonly string? _sourceScope; + private IReadOnlyList _diagnostics = Array.Empty(); + + public DirectoryGameSkillSource( + string directory, + int maximumSkills, + int maximumManifestCharacters, + int maximumInstructionsCharacters, + int maximumScannedDirectories) + : this( + directory, + maximumSkills, + maximumManifestCharacters, + maximumInstructionsCharacters, + maximumScannedDirectories, + maximumIgnoreCharacters: 100_000, + continueOnError: false, + honorIgnoreFiles: true, + source: "local", + sourceScope: null) + { + } public DirectoryGameSkillSource( string directory, int maximumSkills = 1_000, int maximumManifestCharacters = 100_000, int maximumInstructionsCharacters = 1_000_000, - int maximumScannedDirectories = 10_000) + int maximumScannedDirectories = 10_000, + int maximumIgnoreCharacters = 100_000, + bool continueOnError = false, + bool honorIgnoreFiles = true, + string source = "local", + string? sourceScope = null, + int maximumDiagnostics = 1_024, + int maximumDiagnosticCharacters = 64_000) { if (string.IsNullOrWhiteSpace(directory)) { @@ -52,8 +88,23 @@ public DirectoryGameSkillSource( throw new ArgumentOutOfRangeException(nameof(maximumScannedDirectories)); } + if (maximumIgnoreCharacters < 0 || maximumIgnoreCharacters > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumIgnoreCharacters)); + } + + if (maximumDiagnostics < 0 || maximumDiagnostics > 1_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumDiagnostics)); + } + + if (maximumDiagnosticCharacters <= 0 || maximumDiagnosticCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumDiagnosticCharacters)); + } + _root = Path.GetFullPath(directory); - if (!Directory.Exists(_root)) + if (!Directory.Exists(_root) && !continueOnError) { throw new DirectoryNotFoundException($"Skill directory '{_root}' does not exist."); } @@ -62,7 +113,39 @@ public DirectoryGameSkillSource( _maximumManifestCharacters = maximumManifestCharacters; _maximumInstructionsCharacters = maximumInstructionsCharacters; _maximumScannedDirectories = maximumScannedDirectories; - _ = LoadManifests(); + _maximumIgnoreCharacters = maximumIgnoreCharacters; + _maximumDiagnostics = maximumDiagnostics; + _maximumDiagnosticCharacters = maximumDiagnosticCharacters; + _continueOnError = continueOnError; + _honorIgnoreFiles = honorIgnoreFiles; + if (string.IsNullOrWhiteSpace(source)) + { + throw new ArgumentException("A resource source is required.", nameof(source)); + } + + _source = source; + _sourceScope = sourceScope; + var scan = LoadManifests(); + SetDiagnostics(scan.Diagnostics); + } + + public IReadOnlyList Diagnostics => Volatile.Read(ref _diagnostics); + + public GameSkillDiscoveryResult Discover() + { + var scan = LoadManifests(); + var diagnostics = NewDiagnostics(scan.Diagnostics); + var skills = new List(); + foreach (var manifest in scan.Manifests) + { + if (TryLoadSkill(manifest, diagnostics, out var skill)) + { + skills.Add(skill!); + } + } + + SetDiagnostics(diagnostics.Items); + return new GameSkillDiscoveryResult(skills, diagnostics.Items); } public ValueTask> SelectAsync( @@ -76,42 +159,139 @@ public ValueTask> SelectAsync( } var tools = new HashSet(query.AvailableTools, StringComparer.Ordinal); - var selected = LoadManifests() + var scan = LoadManifests(); + var diagnostics = NewDiagnostics(scan.Diagnostics); + var candidates = scan.Manifests + .Where(manifest => !manifest.Document.DisableModelInvocation) .Where(manifest => manifest.Document.InputTypes is null || manifest.Document.InputTypes.Count == 0 || manifest.Document.InputTypes.Contains(query.Input.Type, StringComparer.Ordinal)) .Where(manifest => (manifest.Document.ToolNames ?? new List()).All(tools.Contains)) .OrderByDescending(manifest => manifest.Document.Priority) - .ThenBy(manifest => manifest.Document.Id, StringComparer.Ordinal) - .Take(query.Limit) - .Select(LoadSkill) - .ToArray(); - return new ValueTask>(selected); + .ThenBy(manifest => manifest.Document.Id, StringComparer.Ordinal); + var selected = new List(); + foreach (var manifest in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (selected.Count >= query.Limit) + { + break; + } + + if (TryLoadSkill(manifest, diagnostics, out var skill)) + { + selected.Add(skill!); + } + } + + SetDiagnostics(diagnostics.Items); + return new ValueTask>(Array.AsReadOnly(selected.ToArray())); } - private IReadOnlyList LoadManifests() + private ManifestScan LoadManifests() { - var manifests = EnumerateSkillDescriptors(_maximumScannedDirectories) + var diagnostics = NewDiagnostics(); + var descriptors = EnumerateSkillDescriptors(diagnostics) .OrderBy(path => path, StringComparer.Ordinal) .Take(_maximumSkills + 1) .ToArray(); - if (manifests.Length > _maximumSkills) + if (descriptors.Length > _maximumSkills) { - throw new GameRuntimeLimitException(nameof(_maximumSkills), "The directory contains too many skills."); + var exception = new GameRuntimeLimitException( + "maximumSkills", + "The directory contains too many skills."); + if (!_continueOnError) + { + throw exception; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.LimitExceeded, exception.Message, _root)); + descriptors = descriptors.Take(_maximumSkills).ToArray(); } - var loaded = manifests.Select(path => LoadManifest( - path, - _maximumManifestCharacters, - _maximumInstructionsCharacters)).ToArray(); - var duplicate = loaded.GroupBy(item => item.Document.Id, StringComparer.Ordinal) - .FirstOrDefault(group => group.Count() > 1); - if (duplicate is not null) + var loaded = new List(); + foreach (var path in descriptors) { - throw new PersistenceException($"Duplicate skill ID '{duplicate.Key}'."); + try + { + var manifest = LoadManifest(path, _maximumManifestCharacters, _maximumInstructionsCharacters); + loaded.Add(manifest); + if (manifest.IsMarkdown) + { + var parentName = new DirectoryInfo(Path.GetDirectoryName(path)!).Name; + if (!string.Equals(manifest.Document.Name, parentName, StringComparison.Ordinal)) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.InvalidMetadata, + $"Skill name '{manifest.Document.Name}' does not match parent directory '{parentName}'.", + path)); + } + } + } + catch (Exception exception) when (IsResourceFailure(exception)) + { + if (!_continueOnError && !IsLooseRootMarkdown(path)) + { + throw; + } + + diagnostics.Add(Warning( + IsLooseRootMarkdown(path) + ? GameResourceDiagnosticCodes.InvalidMetadata + : DiagnosticCode(exception), + exception.Message, + path)); + } } - return loaded; + var deduplicated = new List(); + foreach (var group in loaded.GroupBy(item => item.Document.Id, StringComparer.Ordinal)) + { + var ordered = group.OrderBy(item => item.DescriptorPath, StringComparer.Ordinal).ToArray(); + deduplicated.Add(ordered[0]); + if (ordered.Length <= 1) + { + continue; + } + + var message = $"Duplicate skill ID '{group.Key}'."; + if (!_continueOnError) + { + throw new PersistenceException(message); + } + + foreach (var duplicate in ordered.Skip(1)) + { + diagnostics.Add(Warning(GameResourceDiagnosticCodes.InvalidMetadata, message, duplicate.DescriptorPath)); + } + } + + return new ManifestScan( + deduplicated.OrderBy(value => value.DescriptorPath, StringComparer.Ordinal).ToArray(), + diagnostics.Items); + } + + private bool TryLoadSkill( + Manifest manifest, + GameResourceDiagnosticBuffer diagnostics, + out GameSkill? skill) + { + try + { + skill = LoadSkill(manifest); + return true; + } + catch (Exception exception) when (IsResourceFailure(exception)) + { + if (!_continueOnError) + { + throw; + } + + diagnostics.Add(Warning(DiagnosticCode(exception), exception.Message, manifest.InstructionsPath)); + skill = null; + return false; + } } private GameSkill LoadSkill(Manifest manifest) @@ -121,7 +301,9 @@ private GameSkill LoadSkill(Manifest manifest) manifest.InstructionsPath, _maximumManifestCharacters, _maximumInstructionsCharacters) - : ReadBounded(manifest.InstructionsPath, _maximumInstructionsCharacters); + : GameResourceFileSupport.ReadBounded( + manifest.InstructionsPath, + _maximumInstructionsCharacters); return new GameSkill( manifest.Document.Id, manifest.Document.Name, @@ -130,7 +312,13 @@ private GameSkill LoadSkill(Manifest manifest) manifest.Document.InputTypes, manifest.Document.ToolNames, manifest.Document.Priority, - manifest.Document.Metadata); + manifest.Document.Metadata, + manifest.Document.DisableModelInvocation, + GameResourceFileSupport.SourceInfo( + _source, + _sourceScope, + _root, + manifest.DescriptorPath)); } private static Manifest LoadManifest( @@ -138,16 +326,14 @@ private static Manifest LoadManifest( int maximumManifestCharacters, int maximumInstructionsCharacters) { - return string.Equals(Path.GetFileName(manifestPath), "SKILL.md", StringComparison.OrdinalIgnoreCase) + return manifestPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? LoadMarkdownSkill(manifestPath, maximumManifestCharacters, maximumInstructionsCharacters) : LoadJsonManifest(manifestPath, maximumManifestCharacters); } - private static Manifest LoadJsonManifest( - string manifestPath, - int maximumManifestCharacters) + private static Manifest LoadJsonManifest(string manifestPath, int maximumManifestCharacters) { - var manifestText = ReadBounded(manifestPath, maximumManifestCharacters); + var manifestText = GameResourceFileSupport.ReadBounded(manifestPath, maximumManifestCharacters); ManifestDocument manifest; try { @@ -193,7 +379,7 @@ private static Manifest LoadJsonManifest( throw new PersistenceException($"Skill file '{instructionsPath}' cannot be a symbolic link or reparse point."); } - return new Manifest(manifest, instructionsPath); + return new Manifest(manifest, manifestPath, instructionsPath); } private static void EnsureManifestIsUnambiguous(JsonElement value, string path) @@ -225,47 +411,29 @@ private static Manifest LoadMarkdownSkill( int maximumManifestCharacters, int maximumInstructionsCharacters) { - _ = maximumInstructionsCharacters; - var attributes = File.GetAttributes(path); - if ((attributes & FileAttributes.ReparsePoint) != 0) - { - throw new PersistenceException($"Skill file '{path}' cannot be a symbolic link or reparse point."); - } - - using var reader = new StreamReader(path); - if (!string.Equals(reader.ReadLine(), "---", StringComparison.Ordinal)) + var maximumFileCharacters = checked(maximumManifestCharacters + maximumInstructionsCharacters + 16); + var frontMatter = GameResourceFileSupport.ParseFrontMatter( + GameResourceFileSupport.ReadBounded(path, maximumFileCharacters), + path); + if (!frontMatter.HasFrontMatter) { throw new PersistenceException($"Skill file '{path}' requires YAML front matter."); } - var metadata = new System.Text.StringBuilder(); - while (true) + if (frontMatter.MetadataCharacters > maximumManifestCharacters) { - var line = reader.ReadLine(); - if (line is null) - { - throw new PersistenceException($"Skill file '{path}' has unterminated YAML front matter."); - } - - if (string.Equals(line, "---", StringComparison.Ordinal)) - { - break; - } - - if (metadata.Length + line.Length + 1 > maximumManifestCharacters) - { - throw new GameRuntimeLimitException(nameof(maximumManifestCharacters), $"Skill metadata in '{path}' exceeds its configured character limit."); - } - - metadata.AppendLine(line); + throw new GameRuntimeLimitException( + nameof(maximumManifestCharacters), + $"Skill metadata in '{path}' exceeds its configured character limit."); } - var values = ParseScalarFrontMatter(metadata.ToString(), path); var directoryName = new DirectoryInfo(Path.GetDirectoryName(path)!).Name; - var name = values.TryGetValue("name", out var configuredName) && !string.IsNullOrWhiteSpace(configuredName) + var name = frontMatter.Values.TryGetValue("name", out var configuredName) + && !string.IsNullOrWhiteSpace(configuredName) ? configuredName : directoryName; - if (!values.TryGetValue("description", out var description) || string.IsNullOrWhiteSpace(description)) + if (!frontMatter.Values.TryGetValue("description", out var description) + || string.IsNullOrWhiteSpace(description)) { throw new PersistenceException($"Skill file '{path}' requires a description."); } @@ -276,13 +444,22 @@ private static Manifest LoadMarkdownSkill( throw new PersistenceException($"Skill file '{path}' has a description longer than 1024 characters."); } + var disableModelInvocation = false; + if (frontMatter.Values.TryGetValue("disable-model-invocation", out var configuredDisable) + && !bool.TryParse(configuredDisable, out disableModelInvocation)) + { + throw new PersistenceException( + $"Skill file '{path}' requires disable-model-invocation to be true or false."); + } + var document = new ManifestDocument { Id = name, Name = name, Description = description, + DisableModelInvocation = disableModelInvocation, }; - return new Manifest(document, path, isMarkdown: true); + return new Manifest(document, path, path, isMarkdown: true); } private static string ReadMarkdownInstructions( @@ -291,62 +468,43 @@ private static string ReadMarkdownInstructions( int maximumInstructionsCharacters) { var maximumFileCharacters = checked(maximumManifestCharacters + maximumInstructionsCharacters + 16); - var text = ReadBounded(path, maximumFileCharacters) - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n'); - var end = text.IndexOf("\n---\n", 4, StringComparison.Ordinal); - if (!text.StartsWith("---\n", StringComparison.Ordinal) || end < 0) + var frontMatter = GameResourceFileSupport.ParseFrontMatter( + GameResourceFileSupport.ReadBounded(path, maximumFileCharacters), + path); + if (!frontMatter.HasFrontMatter) { throw new PersistenceException($"Skill file '{path}' has invalid YAML front matter."); } - var instructions = text.Substring(end + 5).Trim(); - if (instructions.Length > maximumInstructionsCharacters) + if (frontMatter.MetadataCharacters > maximumManifestCharacters) { - throw new GameRuntimeLimitException(nameof(maximumInstructionsCharacters), $"Skill instructions in '{path}' exceed their configured character limit."); + throw new GameRuntimeLimitException( + nameof(maximumManifestCharacters), + $"Skill metadata in '{path}' exceeds its configured character limit."); } - return instructions; - } - - private static IReadOnlyDictionary ParseScalarFrontMatter(string text, string path) - { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var rawLine in text.Split('\n')) + if (frontMatter.Body.Length > maximumInstructionsCharacters) { - var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith('#')) - { - continue; - } - - var separator = line.IndexOf(':'); - if (separator <= 0) - { - throw new PersistenceException($"Skill file '{path}' contains unsupported YAML metadata."); - } - - var key = line.Substring(0, separator).Trim(); - var value = line.Substring(separator + 1).Trim(); - if (value.Length >= 2 - && ((value.StartsWith('"') && value.EndsWith('"')) - || (value.StartsWith('\'') && value.EndsWith('\''))) - ) - { - value = value.Substring(1, value.Length - 2); - } - - if (!values.TryAdd(key, value)) - { - throw new PersistenceException($"Skill file '{path}' contains duplicate YAML metadata '{key}'."); - } + throw new GameRuntimeLimitException( + nameof(maximumInstructionsCharacters), + $"Skill instructions in '{path}' exceed their configured character limit."); } - return values; + return frontMatter.Body; } - private IEnumerable EnumerateSkillDescriptors(int maximumScannedDirectories) + private IEnumerable EnumerateSkillDescriptors(GameResourceDiagnosticBuffer diagnostics) { + if (!Directory.Exists(_root)) + { + yield break; + } + + var ignore = new GameIgnoreMatcher( + _root, + _maximumIgnoreCharacters, + _source, + _sourceScope); var pending = new Stack(); pending.Push(_root); var scanned = 0; @@ -354,54 +512,194 @@ private IEnumerable EnumerateSkillDescriptors(int maximumScannedDirector { var directory = pending.Pop(); scanned++; - if (scanned > maximumScannedDirectories) + if (scanned > _maximumScannedDirectories) { - throw new GameRuntimeLimitException( - nameof(maximumScannedDirectories), + var exception = new GameRuntimeLimitException( + "maximumScannedDirectories", "The skill directory tree exceeds its configured scan limit."); + if (!_continueOnError) + { + throw exception; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.LimitExceeded, exception.Message, directory)); + yield break; + } + + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (!_continueOnError) + { + throw; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.FileInfoFailed, exception.Message, directory)); + continue; } - if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) + if ((attributes & FileAttributes.ReparsePoint) != 0) { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.UnsupportedEntry, + $"Skill directory '{directory}' is a symbolic link or reparse point and was skipped.", + directory)); continue; } + if (_honorIgnoreFiles) + { + ignore.AddRules(directory, diagnostics); + } + var json = Path.Combine(directory, "skill.json"); var markdown = Path.Combine(directory, "SKILL.md"); - if (File.Exists(json)) + if (File.Exists(json) && !ignore.IsIgnored(json, isDirectory: false)) { yield return json; continue; } - if (File.Exists(markdown)) + if (File.Exists(markdown) && !ignore.IsIgnored(markdown, isDirectory: false)) { yield return markdown; continue; } - var children = Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly) - .Where(path => + string[] children; + string[] files; + try + { + children = Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly) + .OrderByDescending(path => path, StringComparer.Ordinal) + .ToArray(); + files = string.Equals(directory, _root, StringComparison.Ordinal) + ? Directory.EnumerateFiles(directory, "*.md", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray() + : Array.Empty(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (!_continueOnError) + { + throw; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.ListFailed, exception.Message, directory)); + continue; + } + + foreach (var file in files) + { + if (Path.GetFileName(file).StartsWith(".", StringComparison.Ordinal) + || ignore.IsIgnored(file, isDirectory: false)) + { + continue; + } + + try + { + if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) != 0) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.UnsupportedEntry, + $"Skill file '{file}' is a symbolic link or reparse point and was skipped.", + file)); + continue; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - var name = Path.GetFileName(path); - return !name.StartsWith('.') - && !string.Equals(name, "node_modules", StringComparison.OrdinalIgnoreCase) - && (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0; - }) - .OrderByDescending(path => path, StringComparer.Ordinal) - .ToArray(); + if (!_continueOnError) + { + throw; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.FileInfoFailed, exception.Message, file)); + continue; + } + + yield return file; + } + foreach (var child in children) { + var name = Path.GetFileName(child); + if (name.StartsWith(".", StringComparison.Ordinal) + || string.Equals(name, "node_modules", StringComparison.OrdinalIgnoreCase) + || ignore.IsIgnored(child, isDirectory: true)) + { + continue; + } + + try + { + if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.UnsupportedEntry, + $"Skill directory '{child}' is a symbolic link or reparse point and was skipped.", + child)); + continue; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (!_continueOnError) + { + throw; + } + + diagnostics.Add(Warning(GameResourceDiagnosticCodes.FileInfoFailed, exception.Message, child)); + continue; + } + pending.Push(child); } } } + private GameResourceDiagnostic Warning(string code, string message, string path) => + GameResourceFileSupport.Warning(code, message, path, _source, _sourceScope, _root); + + private bool IsLooseRootMarkdown(string path) => + string.Equals(Path.GetDirectoryName(path), _root, StringComparison.Ordinal) + && path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + && !string.Equals(Path.GetFileName(path), "SKILL.md", StringComparison.OrdinalIgnoreCase); + + private void SetDiagnostics(IEnumerable diagnostics) => + Volatile.Write(ref _diagnostics, Array.AsReadOnly(diagnostics.ToArray())); + + private GameResourceDiagnosticBuffer NewDiagnostics( + IEnumerable? initial = null) => + new(_maximumDiagnostics, _maximumDiagnosticCharacters, initial); + + private static bool IsResourceFailure(Exception exception) => + exception is IOException + or UnauthorizedAccessException + or PersistenceException + or GameRuntimeLimitException + or OverflowException; + + private static string DiagnosticCode(Exception exception) => exception switch + { + GameRuntimeLimitException => GameResourceDiagnosticCodes.LimitExceeded, + IOException or UnauthorizedAccessException => GameResourceDiagnosticCodes.ReadFailed, + _ when exception.Message.Contains("YAML", StringComparison.OrdinalIgnoreCase) + || exception.Message.Contains("JSON", StringComparison.OrdinalIgnoreCase) => GameResourceDiagnosticCodes.ParseFailed, + _ => GameResourceDiagnosticCodes.InvalidMetadata, + }; + private static void ValidatePortableSkillName(string name, string path) { if (name.Length > 64 - || name.StartsWith('-') - || name.EndsWith('-') + || name.StartsWith("-", StringComparison.Ordinal) + || name.EndsWith("-", StringComparison.Ordinal) || name.Contains("--", StringComparison.Ordinal) || name.Any(character => character is not (>= 'a' and <= 'z') @@ -421,31 +719,6 @@ private static void ValidateIds(IReadOnlyCollection? values, string path } } - private static string ReadBounded(string path, int maximumCharacters) - { - var attributes = File.GetAttributes(path); - if ((attributes & FileAttributes.ReparsePoint) != 0) - { - throw new PersistenceException($"Skill file '{path}' cannot be a symbolic link or reparse point."); - } - - using var reader = new StreamReader(path); - var buffer = new char[Math.Min(4096, Math.Max(1, maximumCharacters + 1))]; - var result = new System.Text.StringBuilder(); - while (result.Length <= maximumCharacters) - { - var read = reader.Read(buffer, 0, Math.Min(buffer.Length, maximumCharacters + 1 - result.Length)); - if (read == 0) - { - return result.ToString(); - } - - result.Append(buffer, 0, read); - } - - throw new GameRuntimeLimitException(nameof(maximumCharacters), $"File '{path}' exceeds its configured character limit."); - } - private sealed class ManifestDocument { public string Id { get; set; } = string.Empty; @@ -463,21 +736,45 @@ private sealed class ManifestDocument public int Priority { get; set; } public Dictionary? Metadata { get; set; } + + public bool DisableModelInvocation { get; set; } } private sealed class Manifest { - public Manifest(ManifestDocument document, string instructionsPath, bool isMarkdown = false) + public Manifest( + ManifestDocument document, + string descriptorPath, + string instructionsPath, + bool isMarkdown = false) { Document = document; + DescriptorPath = descriptorPath; InstructionsPath = instructionsPath; IsMarkdown = isMarkdown; } public ManifestDocument Document { get; } + public string DescriptorPath { get; } + public string InstructionsPath { get; } public bool IsMarkdown { get; } } + + private sealed class ManifestScan + { + public ManifestScan( + IReadOnlyList manifests, + IReadOnlyList diagnostics) + { + Manifests = manifests; + Diagnostics = diagnostics; + } + + public IReadOnlyList Manifests { get; } + + public IReadOnlyList Diagnostics { get; } + } } diff --git a/src/OpenGameAgent.Persistence/FileGamePromptTemplateLoader.cs b/src/OpenGameAgent.Persistence/FileGamePromptTemplateLoader.cs new file mode 100644 index 0000000..cd9f341 --- /dev/null +++ b/src/OpenGameAgent.Persistence/FileGamePromptTemplateLoader.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace OpenGameAgent.Persistence; + +public sealed class FileGamePromptTemplateLoader +{ + private readonly IReadOnlyList _paths; + private readonly int _maximumTemplates; + private readonly int _maximumTemplateCharacters; + private readonly int _maximumDiagnostics; + private readonly int _maximumDiagnosticCharacters; + private readonly string _source; + private readonly string? _sourceScope; + + public FileGamePromptTemplateLoader( + string path, + int maximumTemplates = 1_000, + int maximumTemplateCharacters = 1_000_000, + string source = "local", + string? sourceScope = null, + int maximumDiagnostics = 1_024, + int maximumDiagnosticCharacters = 64_000) + : this( + new[] { path ?? throw new ArgumentNullException(nameof(path)) }, + maximumTemplates, + maximumTemplateCharacters, + source, + sourceScope, + maximumDiagnostics, + maximumDiagnosticCharacters) + { + } + + public FileGamePromptTemplateLoader( + IEnumerable paths, + int maximumTemplates = 1_000, + int maximumTemplateCharacters = 1_000_000, + string source = "local", + string? sourceScope = null, + int maximumDiagnostics = 1_024, + int maximumDiagnosticCharacters = 64_000) + { + if (paths is null) + { + throw new ArgumentNullException(nameof(paths)); + } + + if (maximumTemplates < 0 || maximumTemplates > 100_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumTemplates)); + } + + if (maximumTemplateCharacters < 0 || maximumTemplateCharacters > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumTemplateCharacters)); + } + + if (maximumDiagnostics < 0 || maximumDiagnostics > 1_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumDiagnostics)); + } + + if (maximumDiagnosticCharacters <= 0 || maximumDiagnosticCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumDiagnosticCharacters)); + } + + if (string.IsNullOrWhiteSpace(source)) + { + throw new ArgumentException("A resource source is required.", nameof(source)); + } + + var copied = paths.Select(path => + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Prompt template paths cannot be empty.", nameof(paths)); + } + + return Path.GetFullPath(path); + }).ToArray(); + if (copied.Length > 10_000) + { + throw new ArgumentException("Too many prompt template paths were configured.", nameof(paths)); + } + + _paths = Array.AsReadOnly(copied); + _maximumTemplates = maximumTemplates; + _maximumTemplateCharacters = maximumTemplateCharacters; + _maximumDiagnostics = maximumDiagnostics; + _maximumDiagnosticCharacters = maximumDiagnosticCharacters; + _source = source; + _sourceScope = sourceScope; + } + + public GamePromptTemplateLoadResult Load() + { + var templates = new List(); + var diagnostics = new GameResourceDiagnosticBuffer( + _maximumDiagnostics, + _maximumDiagnosticCharacters); + foreach (var path in _paths) + { + if (templates.Count >= _maximumTemplates) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.LimitExceeded, + "The configured prompt template count limit was reached.", + path, + Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path)); + break; + } + + if (Directory.Exists(path)) + { + try + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.UnsupportedEntry, + $"Prompt template directory '{path}' is a symbolic link or reparse point and was skipped.", + path, + path)); + continue; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.FileInfoFailed, + exception.Message, + path, + path)); + continue; + } + + LoadDirectory(path, templates, diagnostics); + } + else if (File.Exists(path) && path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + TryLoadFile(path, Path.GetDirectoryName(path)!, templates, diagnostics); + } + } + + return new GamePromptTemplateLoadResult(templates, diagnostics.Items); + } + + private void LoadDirectory( + string directory, + List templates, + GameResourceDiagnosticBuffer diagnostics) + { + string[] files; + try + { + files = Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly) + .Where(path => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + diagnostics.Add(Warning(GameResourceDiagnosticCodes.ListFailed, exception.Message, directory, directory)); + return; + } + + foreach (var file in files) + { + if (templates.Count >= _maximumTemplates) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.LimitExceeded, + "The configured prompt template count limit was reached.", + file, + directory)); + return; + } + + TryLoadFile(file, directory, templates, diagnostics); + } + } + + private void TryLoadFile( + string path, + string basePath, + List templates, + GameResourceDiagnosticBuffer diagnostics) + { + try + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + diagnostics.Add(Warning( + GameResourceDiagnosticCodes.UnsupportedEntry, + $"Prompt template '{path}' is a symbolic link or reparse point and was skipped.", + path, + basePath)); + return; + } + + var frontMatter = GameResourceFileSupport.ParseFrontMatter( + GameResourceFileSupport.ReadBounded(path, _maximumTemplateCharacters), + path); + var description = frontMatter.Values.TryGetValue("description", out var configuredDescription) + ? configuredDescription + : string.Empty; + if (description.Length == 0) + { + var firstLine = frontMatter.Body.Split('\n').FirstOrDefault(line => line.Trim().Length > 0); + if (firstLine is not null) + { + description = firstLine.Length <= 60 + ? firstLine + : firstLine.Substring(0, 60) + "..."; + } + } + + frontMatter.Values.TryGetValue("argument-hint", out var argumentHint); + templates.Add(new GamePromptTemplate( + Path.GetFileNameWithoutExtension(path), + frontMatter.Body, + description, + argumentHint, + GameResourceFileSupport.SourceInfo( + _source, + _sourceScope, + basePath, + path))); + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or PersistenceException + or GameRuntimeLimitException) + { + var code = exception switch + { + GameRuntimeLimitException => GameResourceDiagnosticCodes.LimitExceeded, + IOException or UnauthorizedAccessException => GameResourceDiagnosticCodes.ReadFailed, + _ => GameResourceDiagnosticCodes.ParseFailed, + }; + diagnostics.Add(Warning(code, exception.Message, path, basePath)); + } + } + + private GameResourceDiagnostic Warning( + string code, + string message, + string path, + string basePath) => + GameResourceFileSupport.Warning( + code, + message, + path, + _source, + _sourceScope, + basePath); +} diff --git a/src/OpenGameAgent.Persistence/FileGameSessionHistoryRepository.cs b/src/OpenGameAgent.Persistence/FileGameSessionHistoryRepository.cs new file mode 100644 index 0000000..56b735c --- /dev/null +++ b/src/OpenGameAgent.Persistence/FileGameSessionHistoryRepository.cs @@ -0,0 +1,1087 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenGameAgent.Persistence; + +public sealed class FileGameHistoryOptions +{ + public FileGameHistoryOptions(string rootDirectory) + { + RootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? throw new ArgumentException("A history root directory is required.", nameof(rootDirectory)) + : rootDirectory; + } + + public string RootDirectory { get; } + public GameHistoryLimits? Limits { get; set; } + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan LockRetryDelay { get; set; } = TimeSpan.FromMilliseconds(20); +} + +public sealed class FileGameSessionHistoryRepository : IGameSessionHistoryRepository +{ + private const int FormatVersion = 1; + private const string Extension = ".ogahistory.jsonl"; + private readonly string _root; + private readonly GameHistoryLimits _limits; + private readonly TimeSpan _lockTimeout; + private readonly TimeSpan _lockRetryDelay; + private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = false }; + + public FileGameSessionHistoryRepository(FileGameHistoryOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + _root = Path.GetFullPath(options.RootDirectory); + _limits = (options.Limits ?? new GameHistoryLimits()).CopyAndValidate(); + _lockTimeout = RequireDuration(options.LockTimeout, TimeSpan.FromMilliseconds(1), TimeSpan.FromMinutes(10), nameof(options.LockTimeout)); + _lockRetryDelay = RequireDuration(options.LockRetryDelay, TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(1), nameof(options.LockRetryDelay)); + Directory.CreateDirectory(_root); + } + + public async Task CreateAsync( + GameHistoryCreateOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new GameHistoryCreateOptions(); + var id = options.Id ?? Guid.NewGuid().ToString("N"); + GameHistoryValidation.SessionId(id, nameof(options.Id), _limits); + GameHistoryValidation.OptionalIdentifier(options.ParentSessionId, nameof(options.ParentSessionId), _limits); + if (options.MetadataJson is not null) + { + GameHistoryValidation.JsonObject(options.MetadataJson, nameof(options.MetadataJson), _limits.MaxPayloadCharacters); + } + + var path = SessionPath(id); + await WithLockPathAsync( + Path.Combine(_root, ".repository.lck"), + () => + { + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(path)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History session already exists: {id}."); + } + + if (CountSessionFiles() >= _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository is full."); + } + + var now = DateTimeOffset.UtcNow; + var metadata = new GameHistoryMetadata(id, now, options.ParentSessionId, options.MetadataJson, now); + try + { + PublishNewSnapshot(path, metadata, new GameHistoryState(_limits)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Failed to create history session {id}.", exception); + } + return true; + }, + cancellationToken).ConfigureAwait(false); + return new GameSessionHistory(new FileGameSessionHistoryStorage(this, id), _limits); + } + + public async Task OpenAsync(string sessionId, CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sessionId, nameof(sessionId), _limits); + await ReadAsync(sessionId, loaded => loaded.Metadata, cancellationToken).ConfigureAwait(false); + return new GameSessionHistory(new FileGameSessionHistoryStorage(this, sessionId), _limits); + } + + public async Task ListAsync( + GameHistoryListQuery? query = null, + CancellationToken cancellationToken = default) + { + query ??= new GameHistoryListQuery(); + var limit = GameHistoryValidation.Limit(query.Limit, _limits); + if (query.AfterSessionId is not null) + { + GameHistoryValidation.SessionId(query.AfterSessionId, nameof(query.AfterSessionId), _limits); + } + + var ordered = await ReadAllMetadataAsync(cancellationToken).ConfigureAwait(false); + var start = CursorStart(ordered, query.AfterSessionId); + var items = ordered.Skip(start).Take(limit).ToArray(); + var next = start + items.Length < ordered.Length ? items.LastOrDefault()?.Id : null; + return new GameHistoryListPage(items, next); + } + + public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sessionId, nameof(sessionId), _limits); + await WithSessionLockAsync( + sessionId, + () => + { + cancellationToken.ThrowIfCancellationRequested(); + var path = SessionPath(sessionId); + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Failed to delete history session {sessionId}.", exception); + } + + return true; + }, + cancellationToken).ConfigureAwait(false); + } + + public async Task ForkAsync( + string sourceSessionId, + GameHistoryForkOptions? options = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sourceSessionId, nameof(sourceSessionId), _limits); + options ??= new GameHistoryForkOptions(); + GameHistoryValidation.Fork(options, _limits); + var targetId = options.Id ?? Guid.NewGuid().ToString("N"); + GameHistoryValidation.SessionId(targetId, nameof(options.Id), _limits); + if (string.Equals(sourceSessionId, targetId, StringComparison.Ordinal)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, "A fork requires a distinct session ID."); + } + + var forkedState = await ReadAsync( + sourceSessionId, + loaded => + { + if (options.ExpectedSourceSequence is { } expected && expected != loaded.State.Sequence) + { + throw new GameHistoryConcurrencyException(expected, loaded.State.Sequence); + } + + return loaded.State.CopyForFork(options); + }, + cancellationToken).ConfigureAwait(false); + + var targetPath = SessionPath(targetId); + await WithLockPathAsync( + Path.Combine(_root, ".repository.lck"), + () => + { + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(targetPath)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History session already exists: {targetId}."); + } + + if (CountSessionFiles() >= _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository is full."); + } + + var now = DateTimeOffset.UtcNow; + var metadata = new GameHistoryMetadata( + targetId, + now, + options.ParentSessionId ?? sourceSessionId, + options.MetadataJson, + now); + try + { + PublishNewSnapshot(targetPath, metadata, forkedState); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Failed to create history fork {targetId}.", exception); + } + return true; + }, + cancellationToken).ConfigureAwait(false); + return new GameSessionHistory(new FileGameSessionHistoryStorage(this, targetId), _limits); + } + + public async Task SearchAsync( + GameHistorySearchQuery query, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Search(query, _limits); + var limit = query.Limit ?? Math.Min(_limits.DefaultQueryResults, _limits.MaxSearchResults); + var sessions = (await ReadAllMetadataAsync(cancellationToken).ConfigureAwait(false)) + .Where(metadata => query.SessionId is null || string.Equals(metadata.Id, query.SessionId, StringComparison.Ordinal)) + .OrderBy(metadata => metadata.Id, StringComparer.Ordinal) + .ToArray(); + var hits = new List(); + var scannedEntries = 0; + var cursorPassed = query.Cursor is null; + foreach (var metadata in sessions) + { + cancellationToken.ThrowIfCancellationRequested(); + var completedCursor = await ReadAsync( + metadata.Id, + loaded => + { + long? entryCursor = null; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = loaded.State.FindEntries(new GameHistoryEntryQuery + { + Type = query.EntryType, + Order = GameHistoryOrder.OldestFirst, + Limit = _limits.MaxQueryResults, + CursorSequence = entryCursor, + }); + foreach (var entry in page.Items) + { + if (++scannedEntries > _limits.MaxSearchScannedEntries) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The search scan limit was exceeded."); + } + + if (!cursorPassed) + { + cursorPassed = string.CompareOrdinal(metadata.Id, query.Cursor!.SessionId) > 0 + || (string.Equals(metadata.Id, query.Cursor.SessionId, StringComparison.Ordinal) + && entry.Sequence > query.Cursor.EntrySequence); + if (!cursorPassed) + { + continue; + } + } + + if (!entry.Type.Contains(query.Text, StringComparison.OrdinalIgnoreCase) + && !entry.PayloadJson.Contains(query.Text, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var snippet = entry.PayloadJson.Length <= 512 ? entry.PayloadJson : entry.PayloadJson.Substring(0, 512); + hits.Add(new GameHistorySearchHit(metadata, entry, snippet)); + if (hits.Count == limit) + { + return new GameHistorySearchCursor(metadata.Id, entry.Sequence); + } + } + + if (page.NextSequence is null) + { + return null; + } + + entryCursor = page.NextSequence; + } + }, + cancellationToken).ConfigureAwait(false); + if (completedCursor is not null) + { + return new GameHistorySearchPage(hits, completedCursor); + } + } + + return new GameHistorySearchPage(hits, null); + } + + private async Task ReadAllMetadataAsync(CancellationToken cancellationToken) + { + string[] files; + try + { + files = Directory.EnumerateFiles(_root, $"*{Extension}", SearchOption.TopDirectoryOnly) + .Take(_limits.MaxSessions + 1) + .ToArray(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, "Failed to list history sessions.", exception); + } + + if (files.Length > _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository contains too many sessions."); + } + + var metadata = new List(files.Length); + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = Path.GetFileName(file); + var id = fileName.Substring(0, fileName.Length - Extension.Length); + try + { + var item = await WithSessionLockAsync( + id, + () => ReadHeaderSafely(file), + cancellationToken).ConfigureAwait(false); + metadata.Add(item); + } + catch (GameHistoryException exception) when (exception.Code is GameHistoryErrorCode.CorruptStorage or GameHistoryErrorCode.NotFound) + { + // Discovery remains usable when one session is corrupt or concurrently deleted; direct open stays strict. + } + } + + return metadata + .OrderByDescending(item => item.ModifiedAt) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .ToArray(); + } + + internal Task ReadAsync(string sessionId, Func read, CancellationToken cancellationToken) => + WithSessionLockAsync( + sessionId, + () => + { + cancellationToken.ThrowIfCancellationRequested(); + return read(LoadSafely(SessionPath(sessionId), cancellationToken)); + }, + cancellationToken); + + internal Task MutateAsync( + string sessionId, + string mutationId, + Func mutation, + CancellationToken cancellationToken) + { + return WithSessionLockAsync( + sessionId, + () => + { + cancellationToken.ThrowIfCancellationRequested(); + var path = SessionPath(sessionId); + var loaded = LoadSafely(path, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var previousSequence = loaded.State.Sequence; + var result = mutation(loaded.State); + if (loaded.State.Sequence == previousSequence) + { + return result; + } + + var item = loaded.State.ExportLog().Last(); + try + { + AppendMutation(path, item); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryCommitException( + mutationId, + outcomeUnknown: true, + $"The durable outcome of history mutation {mutationId} is unknown.", + exception); + } + + return result; + }, + cancellationToken); + } + + private LoadedSession Load(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, $"History session not found: {Path.GetFileNameWithoutExtension(path)}."); + } + + var endsWithNewline = EndsWithNewline(path); + HeaderLine? header = null; + var state = new GameHistoryState(_limits); + var lineNumber = 0; + var repairedTornTail = false; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream, new UTF8Encoding(false, true), detectEncodingFromByteOrderMarks: true, 4096, leaveOpen: false)) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = ReadBoundedLine(reader, EncodedLineLimit()); + if (line is null) + { + break; + } + + lineNumber++; + if (lineNumber == 1) + { + header = DecodeHeader(line, path, lineNumber); + continue; + } + + try + { + state.Replay(DecodeMutation(line, path, lineNumber)); + } + catch (JsonException) when (!endsWithNewline && reader.Peek() < 0) + { + repairedTornTail = true; + break; + } + catch (JsonException exception) + { + throw Corrupt(path, lineNumber, "The history mutation is invalid JSON.", exception); + } + catch (GameHistoryException exception) when (exception.Code is GameHistoryErrorCode.InvalidInput + or GameHistoryErrorCode.AlreadyExists + or GameHistoryErrorCode.NotFound + or GameHistoryErrorCode.InvalidLane + or GameHistoryErrorCode.Conflict) + { + throw Corrupt(path, lineNumber, exception.Message, exception); + } + } + } + + if (header is null) + { + throw Corrupt(path, 1, "The history header is missing."); + } + + var metadata = ValidateHeader(header, path); + if (repairedTornTail) + { + ReplaceSnapshot(path, metadata, state); + } + else if (!endsWithNewline) + { + try + { + using var append = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough); + append.WriteByte((byte)'\n'); + append.Flush(true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, "Failed to repair the history terminator.", exception); + } + } + + var modified = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + metadata = new GameHistoryMetadata(metadata.Id, metadata.CreatedAt, metadata.ParentSessionId, metadata.MetadataJson, modified); + return new LoadedSession(metadata, state); + } + + private LoadedSession LoadSafely(string path, CancellationToken cancellationToken) + { + try + { + return Load(path, cancellationToken); + } + catch (DecoderFallbackException exception) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, $"History file {path} is not valid UTF-8.", exception); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Failed to read history file {path}.", exception); + } + } + + private GameHistoryMetadata ReadHeader(string path) + { + if (!File.Exists(path)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, "The history session was deleted while listing."); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true, 4096, false); + var line = ReadBoundedLine(reader, EncodedLineLimit()) + ?? throw Corrupt(path, 1, "The history header is missing."); + return ValidateHeader(DecodeHeader(line, path, 1), path); + } + + private GameHistoryMetadata ReadHeaderSafely(string path) + { + try + { + return ReadHeader(path); + } + catch (DecoderFallbackException exception) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, $"History header {path} is not valid UTF-8.", exception); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Failed to read history header {path}.", exception); + } + } + + private GameHistoryMetadata ValidateHeader(HeaderLine header, string path) + { + if (!string.Equals(header.Kind, "header", StringComparison.Ordinal) || header.Version != FormatVersion) + { + throw Corrupt(path, 1, "The history header version is unsupported."); + } + + try + { + GameHistoryValidation.SessionId(header.Id ?? string.Empty, nameof(header.Id), _limits); + GameHistoryValidation.OptionalIdentifier(header.ParentSessionId, nameof(header.ParentSessionId), _limits); + if (header.MetadataJson is not null) + { + GameHistoryValidation.JsonObject(header.MetadataJson, nameof(header.MetadataJson), _limits.MaxPayloadCharacters); + } + } + catch (GameHistoryException exception) + { + throw Corrupt(path, 1, exception.Message, exception); + } + + var expectedId = Path.GetFileName(path).Substring(0, Path.GetFileName(path).Length - Extension.Length); + if (!string.Equals(header.Id, expectedId, StringComparison.Ordinal)) + { + throw Corrupt(path, 1, "The history header ID does not match its file name."); + } + + DateTimeOffset created; + try + { + if (header.CreatedUnixMilliseconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(header.CreatedUnixMilliseconds)); + } + + created = DateTimeOffset.FromUnixTimeMilliseconds(header.CreatedUnixMilliseconds); + } + catch (ArgumentOutOfRangeException exception) + { + throw Corrupt(path, 1, "The history creation timestamp is invalid.", exception); + } + + var modified = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + return new GameHistoryMetadata(header.Id!, created, header.ParentSessionId, header.MetadataJson, modified); + } + + private HeaderLine DecodeHeader(string line, string path, int lineNumber) + { + try + { + return JsonSerializer.Deserialize(line, _jsonOptions) + ?? throw Corrupt(path, lineNumber, "The history header is null."); + } + catch (JsonException exception) + { + throw Corrupt(path, lineNumber, "The history header is invalid JSON.", exception); + } + } + + private GameHistoryLogItem DecodeMutation(string line, string path, int lineNumber) + { + MutationLine value; + value = JsonSerializer.Deserialize(line, _jsonOptions) + ?? throw Corrupt(path, lineNumber, "The history mutation is null."); + + if (!string.Equals(value.Kind, "mutation", StringComparison.Ordinal) + || !Enum.TryParse(value.MutationKind, ignoreCase: false, out var kind) + || value.Sequence < 1) + { + throw Corrupt(path, lineNumber, "The history mutation envelope is invalid."); + } + + GameHistoryValidation.Identifier(value.MutationId ?? string.Empty, nameof(value.MutationId), _limits); + GameHistoryEntry? entry = null; + if (value.Entry is not null) + { + GameHistoryValidation.Identifier(value.Entry.Id ?? string.Empty, nameof(value.Entry.Id), _limits); + GameHistoryValidation.OptionalIdentifier(value.Entry.ParentId, nameof(value.Entry.ParentId), _limits); + GameHistoryValidation.Type(value.Entry.Type ?? string.Empty, nameof(value.Entry.Type), _limits); + GameHistoryValidation.Json(value.Entry.PayloadJson ?? string.Empty, nameof(value.Entry.PayloadJson), _limits.MaxPayloadCharacters); + entry = new GameHistoryEntry( + value.Entry.Id!, + value.Sequence, + value.Entry.ParentId, + Timestamp(value.Entry.TimestampUnixMilliseconds, path, lineNumber), + value.Entry.Type!, + value.Entry.PayloadJson!); + } + + GameHistoryRecord? record = null; + if (value.Record is not null) + { + GameHistoryValidation.Identifier(value.Record.Id ?? string.Empty, nameof(value.Record.Id), _limits); + GameHistoryValidation.Identifier(value.Record.Lane ?? string.Empty, nameof(value.Record.Lane), _limits); + GameHistoryValidation.Type(value.Record.Type ?? string.Empty, nameof(value.Record.Type), _limits); + GameHistoryValidation.Json(value.Record.PayloadJson ?? string.Empty, nameof(value.Record.PayloadJson), _limits.MaxPayloadCharacters); + record = new GameHistoryRecord( + value.Record.Id!, + value.Sequence, + Timestamp(value.Record.TimestampUnixMilliseconds, path, lineNumber), + value.Record.Lane!, + value.Record.Type!, + value.Record.PayloadJson!); + } + + GameHistoryValidation.OptionalIdentifier(value.Lane, nameof(value.Lane), _limits); + GameHistoryValidation.OptionalIdentifier(value.LeafEntryId, nameof(value.LeafEntryId), _limits); + GameHistoryValidation.OptionalIdentifier(value.TargetEntryId, nameof(value.TargetEntryId), _limits); + if (value.Name is not null) GameHistoryValidation.Fact(value.Name, nameof(value.Name), _limits); + if (value.Label is not null) GameHistoryValidation.Fact(value.Label, nameof(value.Label), _limits); + ValidateMutationShape(value, kind, entry, record, path, lineNumber); + return new GameHistoryLogItem( + value.MutationId!, + value.Sequence, + kind, + entry, + record, + value.Lane, + value.LeafEntryId, + value.CreatesLane, + value.Name, + value.TargetEntryId, + value.Label); + } + + private static void ValidateMutationShape( + MutationLine value, + GameHistoryMutationKind kind, + GameHistoryEntry? entry, + GameHistoryRecord? record, + string path, + int line) + { + var valid = kind switch + { + GameHistoryMutationKind.Entry => entry is not null + && record is null + && value.CreatesLane is null + && value.Name is null + && value.TargetEntryId is null + && value.Label is null, + GameHistoryMutationKind.Record => entry is null + && record is not null + && string.Equals(value.Lane, record.Lane, StringComparison.Ordinal) + && value.LeafEntryId is null + && value.CreatesLane is null + && value.Name is null + && value.TargetEntryId is null + && value.Label is null, + GameHistoryMutationKind.Lane => entry is null + && record is null + && value.Lane is not null + && value.CreatesLane is not null + && value.Name is null + && value.TargetEntryId is null + && value.Label is null, + GameHistoryMutationKind.Name => entry is null + && record is null + && value.Lane is null + && value.LeafEntryId is null + && value.CreatesLane is null + && value.Name is not null + && value.TargetEntryId is null + && value.Label is null, + GameHistoryMutationKind.Label => entry is null + && record is null + && value.Lane is null + && value.LeafEntryId is null + && value.CreatesLane is null + && value.Name is null + && value.TargetEntryId is not null, + _ => false, + }; + if (!valid) + { + throw Corrupt(path, line, "The history mutation fields do not match its kind."); + } + } + + private void PublishNewSnapshot(string destination, GameHistoryMetadata metadata, GameHistoryState state) + { + var temp = destination + ".stage-" + Guid.NewGuid().ToString("N"); + try + { + WriteSnapshot(temp, metadata, state); + File.Move(temp, destination); + } + catch + { + TryDelete(temp); + throw; + } + } + + private void ReplaceSnapshot(string destination, GameHistoryMetadata metadata, GameHistoryState state) + { + var temp = destination + ".repair-" + Guid.NewGuid().ToString("N"); + try + { + WriteSnapshot(temp, metadata, state); + File.Replace(temp, destination, null); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + TryDelete(temp); + throw new GameHistoryException(GameHistoryErrorCode.Storage, "Failed to publish the torn-tail repair.", exception); + } + } + + private void WriteSnapshot(string path, GameHistoryMetadata metadata, GameHistoryState state) + { + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough); + WriteLine(stream, EncodeHeader(metadata)); + foreach (var item in state.ExportLog()) + { + WriteLine(stream, EncodeMutation(item)); + } + + stream.Flush(true); + } + + private void AppendMutation(string path, GameHistoryLogItem item) + { + using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough); + WriteLine(stream, EncodeMutation(item)); + stream.Flush(true); + } + + private string EncodeHeader(GameHistoryMetadata metadata) => JsonSerializer.Serialize( + new HeaderLine + { + Kind = "header", + Version = FormatVersion, + Id = metadata.Id, + CreatedUnixMilliseconds = metadata.CreatedAt.ToUnixTimeMilliseconds(), + ParentSessionId = metadata.ParentSessionId, + MetadataJson = metadata.MetadataJson, + }, + _jsonOptions); + + private string EncodeMutation(GameHistoryLogItem item) => JsonSerializer.Serialize( + new MutationLine + { + Kind = "mutation", + MutationId = item.MutationId, + Sequence = item.Sequence, + MutationKind = item.Kind.ToString(), + Lane = item.Lane, + LeafEntryId = item.LeafEntryId, + CreatesLane = item.CreatesLane, + Name = item.Name, + TargetEntryId = item.TargetEntryId, + Label = item.Label, + Entry = item.Entry is null + ? null + : new EntryLine + { + Id = item.Entry.Id, + ParentId = item.Entry.ParentId, + TimestampUnixMilliseconds = item.Entry.Timestamp.ToUnixTimeMilliseconds(), + Type = item.Entry.Type, + PayloadJson = item.Entry.PayloadJson, + }, + Record = item.Record is null + ? null + : new RecordLine + { + Id = item.Record.Id, + TimestampUnixMilliseconds = item.Record.Timestamp.ToUnixTimeMilliseconds(), + Lane = item.Record.Lane, + Type = item.Record.Type, + PayloadJson = item.Record.PayloadJson, + }, + }, + _jsonOptions); + + private static void WriteLine(Stream stream, string line) + { + var bytes = new UTF8Encoding(false, true).GetBytes(line + "\n"); + stream.Write(bytes, 0, bytes.Length); + } + + private async Task WithSessionLockAsync(string sessionId, Func operation, CancellationToken cancellationToken) + { + return await WithLockPathAsync(SessionPath(sessionId) + ".lck", operation, cancellationToken).ConfigureAwait(false); + } + + private async Task WithLockPathAsync(string lockPath, Func operation, CancellationToken cancellationToken) + { + var started = DateTimeOffset.UtcNow; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + FileStream? handle = null; + try + { + handle = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) when (DateTimeOffset.UtcNow - started < _lockTimeout) + { + await Task.Delay(_lockRetryDelay, cancellationToken).ConfigureAwait(false); + continue; + } + catch (IOException exception) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, $"Timed out acquiring the history lock {lockPath}.", exception); + } + + using (handle) + { + cancellationToken.ThrowIfCancellationRequested(); + return operation(); + } + } + } + + private string SessionPath(string id) + { + var path = Path.GetFullPath(Path.Combine(_root, id + Extension)); + if (!string.Equals(Path.GetDirectoryName(path), _root, StringComparison.OrdinalIgnoreCase)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, "The history session path escapes its repository."); + } + + return path; + } + + private int CountSessionFiles() + { + int count; + try + { + count = Directory.EnumerateFiles(_root, $"*{Extension}", SearchOption.TopDirectoryOnly) + .Take(_limits.MaxSessions + 1) + .Count(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new GameHistoryException(GameHistoryErrorCode.Storage, "Failed to count history sessions.", exception); + } + if (count > _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository contains too many sessions."); + } + + return count; + } + + private long EncodedLineLimit() => Math.Min(int.MaxValue, _limits.MaxPayloadCharacters * 6L + 65_536L); + + private static string? ReadBoundedLine(StreamReader reader, long maxCharacters) + { + var builder = new StringBuilder(); + while (true) + { + var value = reader.Read(); + if (value < 0) + { + return builder.Length == 0 ? null : builder.ToString(); + } + + if (value == '\n') + { + if (builder.Length > 0 && builder[builder.Length - 1] == '\r') + { + builder.Length--; + } + + return builder.ToString(); + } + + if (builder.Length >= maxCharacters) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "A history line exceeds the configured payload bound."); + } + + builder.Append((char)value); + } + } + + private static bool EndsWithNewline(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length == 0) + { + return false; + } + + stream.Position = stream.Length - 1; + return stream.ReadByte() == '\n'; + } + + private static DateTimeOffset Timestamp(long value, string path, int line) + { + try + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + return DateTimeOffset.FromUnixTimeMilliseconds(value); + } + catch (ArgumentOutOfRangeException exception) + { + throw Corrupt(path, line, "The history mutation timestamp is invalid.", exception); + } + } + + private static int CursorStart(IReadOnlyList values, string? afterId) + { + if (afterId is null) + { + return 0; + } + + for (var index = 0; index < values.Count; index++) + { + if (string.Equals(values[index].Id, afterId, StringComparison.Ordinal)) + { + return index + 1; + } + } + + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The list cursor does not identify a visible session."); + } + + private static TimeSpan RequireDuration(TimeSpan value, TimeSpan min, TimeSpan max, string name) + { + if (value < min || value > max) + { + throw new ArgumentOutOfRangeException(name); + } + + return value; + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static GameHistoryException Corrupt(string path, int line, string message, Exception? inner = null) => + new(GameHistoryErrorCode.CorruptStorage, $"Invalid history file {path} at line {line}: {message}", inner); + + internal sealed class LoadedSession + { + internal LoadedSession(GameHistoryMetadata metadata, GameHistoryState state) + { + Metadata = metadata; + State = state; + } + + internal GameHistoryMetadata Metadata { get; } + internal GameHistoryState State { get; } + } + + private sealed class HeaderLine + { + public string? Kind { get; set; } + public int Version { get; set; } + public string? Id { get; set; } + public long CreatedUnixMilliseconds { get; set; } + public string? ParentSessionId { get; set; } + public string? MetadataJson { get; set; } + } + + private sealed class MutationLine + { + public string? Kind { get; set; } + public string? MutationId { get; set; } + public long Sequence { get; set; } + public string? MutationKind { get; set; } + public EntryLine? Entry { get; set; } + public RecordLine? Record { get; set; } + public string? Lane { get; set; } + public string? LeafEntryId { get; set; } + public bool? CreatesLane { get; set; } + public string? Name { get; set; } + public string? TargetEntryId { get; set; } + public string? Label { get; set; } + } + + private sealed class EntryLine + { + public string? Id { get; set; } + public string? ParentId { get; set; } + public long TimestampUnixMilliseconds { get; set; } + public string? Type { get; set; } + public string? PayloadJson { get; set; } + } + + private sealed class RecordLine + { + public string? Id { get; set; } + public long TimestampUnixMilliseconds { get; set; } + public string? Lane { get; set; } + public string? Type { get; set; } + public string? PayloadJson { get; set; } + } +} + +internal sealed class FileGameSessionHistoryStorage : IGameSessionHistoryStorage +{ + private readonly FileGameSessionHistoryRepository _repository; + private readonly string _sessionId; + + internal FileGameSessionHistoryStorage(FileGameSessionHistoryRepository repository, string sessionId) + { + _repository = repository; + _sessionId = sessionId; + } + + public Task GetMetadataAsync(CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.Metadata, cancellationToken); + + public Task> GetLanesAsync(CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.GetLanes(), cancellationToken); + + public Task GetEntryAsync(string id, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.GetEntry(id), cancellationToken); + + public Task> FindEntriesAsync(GameHistoryEntryQuery query, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.FindEntries(query), cancellationToken); + + public Task> FindBranchAsync(string lane, GameHistoryBranchQuery query, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.FindBranch(lane, query), cancellationToken); + + public Task> FindRecordsAsync(GameHistoryRecordQuery query, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.FindRecords(query), cancellationToken); + + public Task> GetLogAsync(GameHistoryLogQuery query, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.GetLog(query), cancellationToken); + + public Task GetNameAsync(CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.Name, cancellationToken); + + public Task GetLabelAsync(string entryId, CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.GetLabel(entryId), cancellationToken); + + public Task GetStatsAsync(CancellationToken cancellationToken) => + _repository.ReadAsync(_sessionId, loaded => loaded.State.GetStats(), cancellationToken); + + public Task AppendEntryAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.AppendEntry(lane, id, type, payloadJson, mutationId, expectedSequence, DateTimeOffset.UtcNow), cancellationToken); + + public Task AppendRecordAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.AppendRecord(lane, id, type, payloadJson, mutationId, expectedSequence, DateTimeOffset.UtcNow), cancellationToken); + + public Task CreateLaneAsync(string lane, string? atEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.CreateLane(lane, atEntryId, mutationId, expectedSequence), cancellationToken); + + public Task MoveLaneAsync(string lane, string? toEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.MoveLane(lane, toEntryId, mutationId, expectedSequence), cancellationToken); + + public Task SetNameAsync(string name, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.SetName(name, mutationId, expectedSequence), cancellationToken); + + public Task SetLabelAsync(string entryId, string? label, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + _repository.MutateAsync(_sessionId, mutationId, state => state.SetLabel(entryId, label, mutationId, expectedSequence), cancellationToken); +} diff --git a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs index ec15e3d..b4619e6 100644 --- a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using OpenGameAgent.Kernel; namespace OpenGameAgent.Persistence; @@ -81,6 +82,11 @@ public async ValueTask SaveAsync( throw new ArgumentException("A saved snapshot revision must advance by exactly one.", nameof(snapshot)); } + if (current is not null) + { + snapshot.UsageLedger.EnsureExtends(current.UsageLedger); + } + await _files.WriteAtomicAsync(path, Encode(snapshot), cancellationToken).ConfigureAwait(false); return new GameSessionSaveResult(saved: true, snapshot); } @@ -92,7 +98,7 @@ public async ValueTask SaveAsync( private static SessionDocument Encode(GameSessionSnapshot snapshot) => new() { - FormatVersion = 2, + FormatVersion = 3, SessionId = snapshot.Key.SessionId, ActorId = snapshot.Key.ActorId, Revision = snapshot.Revision, @@ -101,6 +107,12 @@ public async ValueTask SaveAsync( PendingInputId = snapshot.PendingInputId, LastMoment = snapshot.LastMoment is null ? null : MomentDocument.Encode(snapshot.LastMoment.Value), ExtensionState = new Dictionary(snapshot.ExtensionState, StringComparer.Ordinal), + UsageRecords = snapshot.UsageLedger.Records.Select(UsageRecordDocument.Encode).ToList(), + UsageRecentRecordCapacity = snapshot.UsageLedger.RecentRecordCapacity, + UsageTotalRecordCount = snapshot.UsageLedger.TotalRecordCount, + UsageTotals = snapshot.UsageLedger.TotalsByCause + .Select(pair => UsageTotalsDocument.Encode(pair.Key, pair.Value)) + .ToList(), }; private static void ValidateKey(GameSessionKey key) @@ -126,7 +138,7 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( return null; } - if (document.FormatVersion is not (1 or 2)) + if (document.FormatVersion is not (1 or 2 or 3)) { throw new PersistenceException($"Unsupported session format version '{document.FormatVersion}'."); } @@ -140,7 +152,47 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( document.ProcessedInputIds ?? new List(), document.LastMoment?.Decode(), document.ExtensionState ?? new Dictionary(StringComparer.Ordinal), - document.FormatVersion >= 2 ? document.PendingInputId : null)); + document.FormatVersion >= 2 ? document.PendingInputId : null, + document.FormatVersion >= 3 + ? DecodeUsageLedger(document) + : null)); + } + + private static GameSessionUsageLedger DecodeUsageLedger(SessionDocument document) + { + var records = (document.UsageRecords ?? new List()) + .Select(record => record.Decode()) + .ToArray(); + var capacity = document.UsageRecentRecordCapacity > 0 + ? document.UsageRecentRecordCapacity + : GameSessionUsageLedger.DefaultRecentRecordCapacity; + if (document.UsageTotals is null && document.UsageTotalRecordCount == 0) + { + // Early v3 previews persisted only raw records. Fold them into the bounded representation. + return new GameSessionUsageLedger(records, capacity); + } + + return GameSessionUsageLedger.Restore( + records, + DecodeUsageTotals(document.UsageTotals), + document.UsageTotalRecordCount, + capacity); + } + + private static IReadOnlyDictionary DecodeUsageTotals( + IReadOnlyList? documents) + { + var totals = new Dictionary(); + foreach (var document in documents ?? Array.Empty()) + { + var cause = (GameSessionUsageCause)document.Cause; + if (!totals.TryAdd(cause, document.Decode())) + { + throw new ArgumentException($"Duplicate cumulative usage cause '{cause}'.", nameof(documents)); + } + } + + return totals; } private sealed class SessionDocument @@ -162,6 +214,135 @@ private sealed class SessionDocument public MomentDocument? LastMoment { get; set; } public Dictionary? ExtensionState { get; set; } + + public List? UsageRecords { get; set; } + + public int UsageRecentRecordCapacity { get; set; } + + public long UsageTotalRecordCount { get; set; } + + public List? UsageTotals { get; set; } + } + + private sealed class UsageRecordDocument + { + public string RecordId { get; set; } = string.Empty; + + public int Cause { get; set; } + + public long InputTokens { get; set; } + + public long OutputTokens { get; set; } + + public long CacheReadTokens { get; set; } + + public long CacheWriteTokens { get; set; } + + public long? ReasoningTokens { get; set; } + + public long? CacheWriteOneHourTokens { get; set; } + + public double InputCost { get; set; } + + public double OutputCost { get; set; } + + public double CacheReadCost { get; set; } + + public double CacheWriteCost { get; set; } + + public string? RunId { get; set; } + + public string? InputId { get; set; } + + public string? DetailsJson { get; set; } + + public static UsageRecordDocument Encode(GameSessionUsageRecord record) => new() + { + RecordId = record.RecordId, + Cause = (int)record.Cause, + InputTokens = record.Usage.InputTokens, + OutputTokens = record.Usage.OutputTokens, + CacheReadTokens = record.Usage.CacheReadTokens, + CacheWriteTokens = record.Usage.CacheWriteTokens, + ReasoningTokens = record.Usage.ReasoningTokens, + CacheWriteOneHourTokens = record.Usage.CacheWriteOneHourTokens, + InputCost = record.Usage.Cost.Input, + OutputCost = record.Usage.Cost.Output, + CacheReadCost = record.Usage.Cost.CacheRead, + CacheWriteCost = record.Usage.Cost.CacheWrite, + RunId = record.RunId, + InputId = record.InputId, + DetailsJson = record.DetailsJson, + }; + + public GameSessionUsageRecord Decode() => new( + RecordId, + (GameSessionUsageCause)Cause, + new ModelUsage( + InputTokens, + OutputTokens, + CacheReadTokens, + CacheWriteTokens, + ReasoningTokens, + CacheWriteOneHourTokens, + new ModelCost(InputCost, OutputCost, CacheReadCost, CacheWriteCost)), + RunId, + InputId, + DetailsJson); + } + + private sealed class UsageTotalsDocument + { + public int Cause { get; set; } + + public long InputTokens { get; set; } + + public long OutputTokens { get; set; } + + public long CacheReadTokens { get; set; } + + public long CacheWriteTokens { get; set; } + + public long ReasoningTokens { get; set; } + + public long CacheWriteOneHourTokens { get; set; } + + public double InputCost { get; set; } + + public double OutputCost { get; set; } + + public double CacheReadCost { get; set; } + + public double CacheWriteCost { get; set; } + + public static UsageTotalsDocument Encode( + GameSessionUsageCause cause, + GameSessionUsageTotals totals) => new() + { + Cause = (int)cause, + InputTokens = totals.InputTokens, + OutputTokens = totals.OutputTokens, + CacheReadTokens = totals.CacheReadTokens, + CacheWriteTokens = totals.CacheWriteTokens, + ReasoningTokens = totals.ReasoningTokens, + CacheWriteOneHourTokens = totals.CacheWriteOneHourTokens, + InputCost = totals.InputCost, + OutputCost = totals.OutputCost, + CacheReadCost = totals.CacheReadCost, + CacheWriteCost = totals.CacheWriteCost, + }; + + public GameSessionUsageTotals Decode() => new( + InputTokens, + OutputTokens, + CacheReadTokens, + CacheWriteTokens, + ReasoningTokens, + CacheWriteOneHourTokens, + InputCost, + OutputCost, + CacheReadCost, + CacheWriteCost); } } diff --git a/src/OpenGameAgent.Persistence/GameResourceFileSupport.cs b/src/OpenGameAgent.Persistence/GameResourceFileSupport.cs new file mode 100644 index 0000000..0959379 --- /dev/null +++ b/src/OpenGameAgent.Persistence/GameResourceFileSupport.cs @@ -0,0 +1,483 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenGameAgent.Persistence; + +internal static class GameResourceFileSupport +{ + public static string ReadBounded(string path, int maximumCharacters, bool rejectReparsePoint = true) + { + if (maximumCharacters < 0) + { + throw new ArgumentOutOfRangeException(nameof(maximumCharacters)); + } + + if (rejectReparsePoint && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new PersistenceException($"Resource file '{path}' cannot be a symbolic link or reparse point."); + } + + using var reader = new StreamReader(path, detectEncodingFromByteOrderMarks: true); + var buffer = new char[Math.Min(4096, Math.Max(1, maximumCharacters + 1))]; + var result = new StringBuilder(); + while (result.Length <= maximumCharacters) + { + var read = reader.Read(buffer, 0, Math.Min(buffer.Length, maximumCharacters + 1 - result.Length)); + if (read == 0) + { + return result.ToString(); + } + + result.Append(buffer, 0, read); + } + + throw new GameRuntimeLimitException(nameof(maximumCharacters), $"File '{path}' exceeds its configured character limit."); + } + + public static FrontMatter ParseFrontMatter(string content, string path) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + var normalized = content + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + if (!normalized.StartsWith("---\n", StringComparison.Ordinal)) + { + return new FrontMatter( + new Dictionary(StringComparer.OrdinalIgnoreCase), + normalized, + hasFrontMatter: false, + metadataCharacters: 0); + } + + var end = normalized.IndexOf("\n---\n", 4, StringComparison.Ordinal); + var bodyStart = end < 0 ? normalized.Length : end + 5; + if (end < 0 && normalized.EndsWith("\n---", StringComparison.Ordinal)) + { + end = normalized.Length - 4; + } + + if (end < 0) + { + return new FrontMatter( + new Dictionary(StringComparer.OrdinalIgnoreCase), + normalized, + hasFrontMatter: false, + metadataCharacters: 0); + } + + var values = ParseScalarFrontMatter(normalized.Substring(4, end - 4), path); + return new FrontMatter( + values, + normalized.Substring(bodyStart).Trim(), + hasFrontMatter: true, + metadataCharacters: end - 4); + } + + public static GameResourceSourceInfo SourceInfo( + string source, + string? scope, + string basePath, + string filePath) => + new(source, basePath, filePath, scope); + + public static GameResourceDiagnostic Warning( + string code, + string message, + string path, + string source, + string? scope, + string basePath) => + new( + GameResourceDiagnosticSeverity.Warning, + code, + message, + path, + SourceInfo(source, scope, basePath, path)); + + private static IReadOnlyDictionary ParseScalarFrontMatter(string text, string path) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + var separator = line.IndexOf(':'); + if (separator <= 0) + { + throw new PersistenceException($"Resource file '{path}' contains unsupported YAML metadata."); + } + + var key = line.Substring(0, separator).Trim(); + var value = line.Substring(separator + 1).Trim(); + if (key.Length == 0) + { + throw new PersistenceException($"Resource file '{path}' contains an empty YAML metadata key."); + } + + if (value.StartsWith("\"", StringComparison.Ordinal) + || value.StartsWith("'", StringComparison.Ordinal)) + { + var quote = value[0]; + if (value.Length < 2 || value[^1] != quote) + { + throw new PersistenceException($"Resource file '{path}' contains unterminated quoted YAML metadata."); + } + + value = value.Substring(1, value.Length - 2); + } + + if (!values.TryAdd(key, value)) + { + throw new PersistenceException($"Resource file '{path}' contains duplicate YAML metadata '{key}'."); + } + } + + return values; + } +} + +internal sealed class FrontMatter +{ + public FrontMatter( + IReadOnlyDictionary values, + string body, + bool hasFrontMatter, + int metadataCharacters) + { + Values = values; + Body = body; + HasFrontMatter = hasFrontMatter; + MetadataCharacters = metadataCharacters; + } + + public IReadOnlyDictionary Values { get; } + + public string Body { get; } + + public bool HasFrontMatter { get; } + + public int MetadataCharacters { get; } +} + +internal sealed class GameResourceDiagnosticBuffer +{ + private readonly List _values = new(); + private readonly int _maximumDiagnostics; + private readonly int _maximumMessageCharacters; + + public GameResourceDiagnosticBuffer( + int maximumDiagnostics, + int maximumMessageCharacters, + IEnumerable? initial = null) + { + _maximumDiagnostics = maximumDiagnostics; + _maximumMessageCharacters = maximumMessageCharacters; + foreach (var diagnostic in initial ?? Array.Empty()) + { + Add(diagnostic); + } + } + + public IReadOnlyList Items => _values; + + public void Add(GameResourceDiagnostic diagnostic) + { + if (diagnostic is null) + { + throw new ArgumentNullException(nameof(diagnostic)); + } + + if (_values.Count >= _maximumDiagnostics) + { + return; + } + + if (diagnostic.Message.Length <= _maximumMessageCharacters) + { + _values.Add(diagnostic); + return; + } + + _values.Add(new GameResourceDiagnostic( + diagnostic.Severity, + diagnostic.Code, + diagnostic.Message.Substring(0, _maximumMessageCharacters), + diagnostic.Path, + diagnostic.SourceInfo)); + } +} + +internal sealed class GameIgnoreMatcher +{ + private static readonly string[] IgnoreFileNames = { ".gitignore", ".ignore", ".fdignore" }; + private static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(100); + private readonly List _rules = new(); + private readonly string _root; + private readonly int _maximumIgnoreCharacters; + private readonly string _source; + private readonly string? _scope; + + public GameIgnoreMatcher( + string root, + int maximumIgnoreCharacters, + string source, + string? scope) + { + _root = root; + _maximumIgnoreCharacters = maximumIgnoreCharacters; + _source = source; + _scope = scope; + } + + public void AddRules(string directory, GameResourceDiagnosticBuffer diagnostics) + { + var relativeDirectory = RelativePath(directory); + foreach (var name in IgnoreFileNames) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) + { + continue; + } + + string content; + try + { + content = GameResourceFileSupport.ReadBounded(path, _maximumIgnoreCharacters); + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or PersistenceException + or GameRuntimeLimitException) + { + diagnostics.Add(GameResourceFileSupport.Warning( + GameResourceDiagnosticCodes.ReadFailed, + exception.Message, + path, + _source, + _scope, + _root)); + continue; + } + + foreach (var rawLine in content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || (line.StartsWith("#", StringComparison.Ordinal) + && !line.StartsWith("\\#", StringComparison.Ordinal))) + { + continue; + } + + var negated = false; + if (line.StartsWith("!", StringComparison.Ordinal)) + { + negated = true; + line = line.Substring(1); + } + else if (line.StartsWith("\\!", StringComparison.Ordinal)) + { + line = line.Substring(1); + } + + if (line.StartsWith("\\#", StringComparison.Ordinal)) + { + line = line.Substring(1); + } + + var anchored = line.StartsWith("/", StringComparison.Ordinal); + if (anchored) + { + line = line.Substring(1); + } + + var directoryOnly = line.EndsWith("/", StringComparison.Ordinal); + line = line.TrimEnd('/'); + if (line.Length == 0) + { + continue; + } + + try + { + _rules.Add(new IgnoreRule( + BuildRegex(relativeDirectory, line, anchored), + negated, + directoryOnly)); + } + catch (ArgumentException exception) + { + diagnostics.Add(GameResourceFileSupport.Warning( + GameResourceDiagnosticCodes.ParseFailed, + exception.Message, + path, + _source, + _scope, + _root)); + } + } + } + } + + public bool IsIgnored(string path, bool isDirectory) + { + var relative = RelativePath(path); + var ignored = false; + foreach (var rule in _rules) + { + try + { + var match = rule.Pattern.Match(relative); + if (match.Success + && (!rule.DirectoryOnly || isDirectory || match.Groups["descendant"].Success)) + { + ignored = !rule.Negated; + } + } + catch (RegexMatchTimeoutException) + { + return true; + } + } + + return ignored; + } + + private static Regex BuildRegex( + string relativeDirectory, + string pattern, + bool anchored) + { + var containsSlash = pattern.Contains('/', StringComparison.Ordinal); + var prefix = relativeDirectory.Length == 0 + ? string.Empty + : Regex.Escape(relativeDirectory + "/"); + var expression = new StringBuilder("^"); + expression.Append(prefix); + if (!anchored && !containsSlash) + { + expression.Append("(?:.*/)?"); + } + + expression.Append(Glob(pattern)); + expression.Append("(?/.*)?$"); + return new Regex(expression.ToString(), RegexOptions.CultureInvariant, MatchTimeout); + } + + private static string Glob(string pattern) + { + var result = new StringBuilder(); + for (var index = 0; index < pattern.Length; index++) + { + var character = pattern[index]; + if (character == '\\' && index + 1 < pattern.Length) + { + index++; + result.Append(Regex.Escape(pattern[index].ToString())); + } + else if (character == '[') + { + var closing = pattern.IndexOf(']', index + 1); + if (closing <= index + 1 + || pattern.Substring(index + 1, closing - index - 1).Contains('/', StringComparison.Ordinal)) + { + result.Append("\\["); + continue; + } + + var characterClass = pattern.Substring(index + 1, closing - index - 1); + result.Append('['); + var classIndex = 0; + if (characterClass.StartsWith("!", StringComparison.Ordinal)) + { + result.Append('^'); + classIndex = 1; + } + else if (characterClass.StartsWith("^", StringComparison.Ordinal)) + { + result.Append("\\^"); + classIndex = 1; + } + + for (; classIndex < characterClass.Length; classIndex++) + { + var classCharacter = characterClass[classIndex]; + if (classCharacter is '\\' or ']') + { + result.Append('\\'); + } + + result.Append(classCharacter); + } + + result.Append(']'); + index = closing; + } + else if (character == '*') + { + var doubleStar = index + 1 < pattern.Length && pattern[index + 1] == '*'; + if (doubleStar) + { + index++; + if (index + 1 < pattern.Length && pattern[index + 1] == '/') + { + index++; + result.Append("(?:.*/)?"); + } + else + { + result.Append(".*"); + } + } + else + { + result.Append("[^/]*"); + } + } + else if (character == '?') + { + result.Append("[^/]"); + } + else + { + result.Append(Regex.Escape(character.ToString())); + } + } + + return result.ToString(); + } + + private string RelativePath(string path) + { + var relative = Path.GetRelativePath(_root, path).Replace('\\', '/'); + return string.Equals(relative, ".", StringComparison.Ordinal) ? string.Empty : relative.Trim('/'); + } + + private sealed class IgnoreRule + { + public IgnoreRule(Regex pattern, bool negated, bool directoryOnly) + { + Pattern = pattern; + Negated = negated; + DirectoryOnly = directoryOnly; + } + + public Regex Pattern { get; } + + public bool Negated { get; } + + public bool DirectoryOnly { get; } + } +} diff --git a/src/OpenGameAgent.Persistence/packages.lock.json b/src/OpenGameAgent.Persistence/packages.lock.json index 484c31c..8249b3b 100644 --- a/src/OpenGameAgent.Persistence/packages.lock.json +++ b/src/OpenGameAgent.Persistence/packages.lock.json @@ -74,7 +74,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -82,6 +83,12 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } } } } diff --git a/src/OpenGameAgent.ProviderTransport/OpenGameAgent.ProviderTransport.csproj b/src/OpenGameAgent.ProviderTransport/OpenGameAgent.ProviderTransport.csproj new file mode 100644 index 0000000..f56aa37 --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/OpenGameAgent.ProviderTransport.csproj @@ -0,0 +1,7 @@ + + + netstandard2.1 + OpenGameAgent.ProviderTransport + Bounded provider transport metadata, observation, and retry primitives for OpenGameAgent. + + diff --git a/src/OpenGameAgent.ProviderTransport/ProviderCallbackRunner.cs b/src/OpenGameAgent.ProviderTransport/ProviderCallbackRunner.cs new file mode 100644 index 0000000..cf6f1bc --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/ProviderCallbackRunner.cs @@ -0,0 +1,57 @@ +namespace OpenGameAgent.ProviderTransport; + +public static class ProviderCallbackRunner +{ + public static async ValueTask RunAsync( + Func> callback, + CancellationToken cancellationToken = default) + { + if (callback is null) + { + throw new ArgumentNullException(nameof(callback)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var pending = callback(cancellationToken); + if (pending.IsCompletedSuccessfully) + { + return pending.Result; + } + + var operation = pending.AsTask(); + if (operation.IsCompleted) + { + return await operation.ConfigureAwait(false); + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static value => ((TaskCompletionSource)value!).TrySetResult(null), + canceled); + if (await Task.WhenAny(operation, canceled.Task).ConfigureAwait(false) == operation) + { + return await operation.ConfigureAwait(false); + } + + ObserveFailure(operation); + cancellationToken.ThrowIfCancellationRequested(); + throw new OperationCanceledException(cancellationToken); + } + + private static void ObserveFailure(Task task) + { + _ = ObserveFailureAsync(task); + } + + private static async Task ObserveFailureAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // A callback that outlives its caller cannot surface a late failure. + } + } +} diff --git a/src/OpenGameAgent.ProviderTransport/ProviderHeaderGuard.cs b/src/OpenGameAgent.ProviderTransport/ProviderHeaderGuard.cs new file mode 100644 index 0000000..0d6c993 --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/ProviderHeaderGuard.cs @@ -0,0 +1,69 @@ +namespace OpenGameAgent.ProviderTransport; + +public static class ProviderHeaderGuard +{ + public const int MaximumHeaders = 64; + public const int MaximumNameCharacters = 256; + public const int MaximumValueCharacters = 65_536; + + public static void Validate( + IEnumerable> headers, + string parameterName) + { + ValidateCore(headers.Select(pair => + new KeyValuePair(pair.Key, pair.Value)), parameterName, allowDeletion: false); + } + + public static void ValidateMerge( + IEnumerable> headers, + string parameterName) + { + ValidateCore(headers, parameterName, allowDeletion: true); + } + + private static void ValidateCore( + IEnumerable> headers, + string parameterName, + bool allowDeletion) + { + if (headers is null) + { + throw new ArgumentNullException(nameof(headers)); + } + + var count = 0; + foreach (var header in headers) + { + count++; + if (count > MaximumHeaders + || string.IsNullOrWhiteSpace(header.Key) + || header.Key.Length > MaximumNameCharacters + || header.Key.Any(character => !IsHeaderNameCharacter(character)) + || IsTransportControlledHeader(header.Key) + || !allowDeletion && header.Value is null + || (header.Value?.Length ?? 0) > MaximumValueCharacters + || header.Value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("Provider headers exceed their count or character bounds.", parameterName); + } + } + } + + public static bool IsTransportControlledHeader(string? name) => + string.Equals(name, "Host", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Keep-Alive", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Proxy-Connection", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TE", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Trailer", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Upgrade", StringComparison.OrdinalIgnoreCase) + || name?.StartsWith("Sec-WebSocket-", StringComparison.OrdinalIgnoreCase) == true; + + private static bool IsHeaderNameCharacter(char value) => + value is >= 'a' and <= 'z' + || value is >= 'A' and <= 'Z' + || value is >= '0' and <= '9' + || value is '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~'; +} diff --git a/src/OpenGameAgent.ProviderTransport/ProviderHttpRetryMetadata.cs b/src/OpenGameAgent.ProviderTransport/ProviderHttpRetryMetadata.cs new file mode 100644 index 0000000..0fa3a75 --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/ProviderHttpRetryMetadata.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using System.Net.Http; + +namespace OpenGameAgent.ProviderTransport; + +public sealed class ProviderHttpRetryMetadata +{ + public static readonly TimeSpan DefaultMaximumServerRetryDelay = TimeSpan.FromSeconds(60); + + private ProviderHttpRetryMetadata(bool isTransient, TimeSpan? retryAfter) + { + IsTransient = isTransient; + RetryAfter = retryAfter; + } + + public bool IsTransient { get; } + + public TimeSpan? RetryAfter { get; } + + public static ProviderHttpRetryMetadata FromResponse( + HttpResponseMessage response, + DateTimeOffset? now = null, + string? errorText = null, + TimeSpan? maximumServerRetryDelay = null) + { + if (response is null) + { + throw new ArgumentNullException(nameof(response)); + } + + var directive = FirstHeader(response, "x-should-retry"); + var status = (int)response.StatusCode; + var retryAfter = ResolveRetryAfter(response, now ?? DateTimeOffset.UtcNow); + var isTransient = status == 429 && IsTerminalQuotaError(errorText) + ? false + : string.Equals(directive, "true", StringComparison.OrdinalIgnoreCase) + || !string.Equals(directive, "false", StringComparison.OrdinalIgnoreCase) + && IsRetryableStatus(status); + var maximumDelay = maximumServerRetryDelay ?? DefaultMaximumServerRetryDelay; + if (maximumDelay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(maximumServerRetryDelay)); + } + + if (maximumDelay > TimeSpan.Zero && retryAfter > maximumDelay) + { + isTransient = false; + } + + return new ProviderHttpRetryMetadata(isTransient, retryAfter); + } + + public static ProviderHttpRetryMetadata FromStatus( + int? statusCode, + bool? providerRetryable = null, + TimeSpan? retryAfter = null) + { + if (statusCode is < 100 or > 599) + { + throw new ArgumentOutOfRangeException(nameof(statusCode)); + } + + if (retryAfter is { } delay && delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(retryAfter)); + } + + var isTransient = providerRetryable ?? statusCode is null || IsRetryableStatus(statusCode.Value); + if (retryAfter > DefaultMaximumServerRetryDelay) + { + isTransient = false; + } + + return new ProviderHttpRetryMetadata(isTransient, retryAfter); + } + + private static bool IsRetryableStatus(int statusCode) => + statusCode is 408 or 409 or 429 || statusCode >= 500; + + private static bool IsTerminalQuotaError(string? errorText) + { + if (string.IsNullOrEmpty(errorText)) + { + return false; + } + + var bounded = errorText.Length <= 65_536 ? errorText : errorText.Substring(0, 65_536); + return bounded.IndexOf("GoUsageLimitError", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("FreeUsageLimitError", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("Monthly usage limit reached", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("available balance", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("insufficient_quota", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("out of budget", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("quota exceeded", StringComparison.OrdinalIgnoreCase) >= 0 + || bounded.IndexOf("billing", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static TimeSpan? ResolveRetryAfter(HttpResponseMessage response, DateTimeOffset now) + { + var millisecondsValue = FirstHeader(response, "retry-after-ms"); + if (double.TryParse(millisecondsValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) + && !double.IsNaN(milliseconds) + && !double.IsInfinity(milliseconds)) + { + return FromMilliseconds(milliseconds); + } + + if (response.Headers.RetryAfter?.Delta is { } delta) + { + return delta < TimeSpan.Zero ? TimeSpan.Zero : delta; + } + + if (response.Headers.RetryAfter?.Date is { } date) + { + var delay = date - now; + return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + } + + return null; + } + + private static TimeSpan FromMilliseconds(double milliseconds) + { + if (milliseconds <= 0) + { + return TimeSpan.Zero; + } + + return milliseconds >= TimeSpan.MaxValue.TotalMilliseconds + ? TimeSpan.MaxValue + : TimeSpan.FromMilliseconds(milliseconds); + } + + private static string? FirstHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; +} diff --git a/src/OpenGameAgent.ProviderTransport/ProviderResponseObservation.cs b/src/OpenGameAgent.ProviderTransport/ProviderResponseObservation.cs new file mode 100644 index 0000000..0e4bdc2 --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/ProviderResponseObservation.cs @@ -0,0 +1,211 @@ +using System.Collections.ObjectModel; +using System.Net.Http; +using System.Text; + +namespace OpenGameAgent.ProviderTransport; + +public sealed class ProviderResponseObservation +{ + private const int MaximumIdentifierCharacters = 256; + private const int MaximumModelCharacters = 1_024; + private const int MaximumMetadataValueCharacters = 1_024; + private const int MaximumMetadataEntriesToInspect = 256; + private static readonly HashSet AllowedHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "anthropic-request-id", + "request-id", + "retry-after", + "retry-after-ms", + "ratelimit-limit", + "ratelimit-remaining", + "ratelimit-reset", + "x-amzn-requestid", + "x-amz-request-id", + "x-goog-request-id", + "x-request-id", + "x-ratelimit-limit-input-tokens", + "x-ratelimit-limit-output-tokens", + "x-ratelimit-limit-requests", + "x-ratelimit-limit-tokens", + "x-ratelimit-remaining-input-tokens", + "x-ratelimit-remaining-output-tokens", + "x-ratelimit-remaining-requests", + "x-ratelimit-remaining-tokens", + "x-ratelimit-reset-input-tokens", + "x-ratelimit-reset-output-tokens", + "x-ratelimit-reset-requests", + "x-ratelimit-reset-tokens", + }; + + private ProviderResponseObservation( + string providerId, + string apiId, + string model, + int statusCode, + IReadOnlyDictionary metadata) + { + ProviderId = RequireBounded(providerId, MaximumIdentifierCharacters, nameof(providerId)); + ApiId = RequireBounded(apiId, MaximumIdentifierCharacters, nameof(apiId)); + Model = RequireBounded(model, MaximumModelCharacters, nameof(model)); + if (statusCode is < 100 or > 599) + { + throw new ArgumentOutOfRangeException(nameof(statusCode)); + } + + StatusCode = statusCode; + Metadata = metadata; + } + + public string ProviderId { get; } + + public string ApiId { get; } + + public string Model { get; } + + public int StatusCode { get; } + + public IReadOnlyDictionary Metadata { get; } + + public static ProviderResponseObservation FromHttpResponse( + string providerId, + string apiId, + string model, + HttpResponseMessage response) + { + if (response is null) + { + throw new ArgumentNullException(nameof(response)); + } + + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in response.Headers) + { + if (!AllowedHeaders.Contains(header.Key)) + { + continue; + } + + metadata[header.Key.ToLowerInvariant()] = BoundedHeaderValue(header.Value); + } + + return new ProviderResponseObservation( + providerId, + apiId, + model, + (int)response.StatusCode, + new ReadOnlyDictionary(metadata)); + } + + public static ProviderResponseObservation FromProviderResponse( + string providerId, + string apiId, + string model, + int statusCode, + string? requestId = null) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(requestId)) + { + metadata["request-id"] = SanitizeAndBound(requestId, MaximumMetadataValueCharacters); + } + + return new ProviderResponseObservation( + providerId, + apiId, + model, + statusCode, + new ReadOnlyDictionary(metadata)); + } + + public static ProviderResponseObservation FromResponseMetadata( + string providerId, + string apiId, + string model, + int statusCode, + IReadOnlyDictionary? responseHeaders) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + var inspected = 0; + foreach (var header in responseHeaders ?? new Dictionary()) + { + inspected++; + if (inspected > MaximumMetadataEntriesToInspect) + { + break; + } + + if (!AllowedHeaders.Contains(header.Key)) + { + continue; + } + + metadata[header.Key.ToLowerInvariant()] = SanitizeAndBound( + header.Value ?? string.Empty, + MaximumMetadataValueCharacters); + } + } + catch + { + metadata.Clear(); + } + + return new ProviderResponseObservation( + providerId, + apiId, + model, + statusCode, + new ReadOnlyDictionary(metadata)); + } + + private static string BoundedHeaderValue(IEnumerable values) + { + var builder = new StringBuilder(Math.Min(MaximumMetadataValueCharacters, 128)); + foreach (var value in values) + { + if (builder.Length > 0 && builder.Length < MaximumMetadataValueCharacters) + { + builder.Append(','); + } + + AppendSanitized(builder, value, MaximumMetadataValueCharacters); + if (builder.Length >= MaximumMetadataValueCharacters) + { + break; + } + } + + return builder.ToString(); + } + + private static string SanitizeAndBound(string value, int maximumCharacters) + { + var builder = new StringBuilder(Math.Min(value.Length, maximumCharacters)); + AppendSanitized(builder, value, maximumCharacters); + return builder.ToString(); + } + + private static void AppendSanitized(StringBuilder builder, string? value, int maximumCharacters) + { + if (value is null) + { + return; + } + + for (var index = 0; index < value.Length && builder.Length < maximumCharacters; index++) + { + var character = value[index]; + builder.Append(character is '\r' or '\n' or '\0' || char.IsControl(character) ? ' ' : character); + } + } + + private static string RequireBounded(string value, int maximumCharacters, string parameterName) => + string.IsNullOrWhiteSpace(value) || value.Length > maximumCharacters + ? throw new ArgumentException($"A value of 1 to {maximumCharacters} characters is required.", parameterName) + : value; +} + +public delegate ValueTask ProviderResponseObserver( + ProviderResponseObservation observation, + CancellationToken cancellationToken); diff --git a/src/OpenGameAgent.ProviderTransport/ProviderResponseObserverRunner.cs b/src/OpenGameAgent.ProviderTransport/ProviderResponseObserverRunner.cs new file mode 100644 index 0000000..aebd5da --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/ProviderResponseObserverRunner.cs @@ -0,0 +1,143 @@ +using System.Runtime.CompilerServices; + +namespace OpenGameAgent.ProviderTransport; + +public enum ProviderResponseObserverOutcome +{ + NotConfigured, + Completed, + Failed, + TimedOut, + Suppressed, +} + +public static class ProviderResponseObserverRunner +{ + public const int DefaultTimeoutMilliseconds = 500; + public const int MaximumConcurrentObservers = 64; + + private static readonly ConditionalWeakTable States = new(); + private static int activeObservers; + + public static async ValueTask NotifyAsync( + ProviderResponseObserver? observer, + ProviderResponseObservation observation, + int timeoutMilliseconds = DefaultTimeoutMilliseconds, + CancellationToken cancellationToken = default) + { + if (observation is null) + { + throw new ArgumentNullException(nameof(observation)); + } + + if (timeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (observer is null) + { + return ProviderResponseObserverOutcome.NotConfigured; + } + + var state = States.GetValue(observer, static _ => new ObserverState()); + if (!state.TryEnter()) + { + return ProviderResponseObserverOutcome.Suppressed; + } + + var active = Interlocked.Increment(ref activeObservers); + if (active > MaximumConcurrentObservers) + { + Interlocked.Decrement(ref activeObservers); + state.Exit(); + return ProviderResponseObserverOutcome.Suppressed; + } + + var observerCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var observerTask = Task.Run( + async () => + { + try + { + await observer(observation, observerCancellation.Token).ConfigureAwait(false); + } + finally + { + observerCancellation.Dispose(); + Interlocked.Decrement(ref activeObservers); + state.Exit(); + } + }); + + using var timeoutCancellation = new CancellationTokenSource(); + var timeoutTask = Task.Delay(timeoutMilliseconds, timeoutCancellation.Token); + var callerCancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var callerRegistration = cancellationToken.Register( + static value => ((TaskCompletionSource)value!).TrySetResult(null), + callerCancellation); + var completed = await Task.WhenAny(observerTask, timeoutTask, callerCancellation.Task).ConfigureAwait(false); + + if (completed == observerTask) + { + timeoutCancellation.Cancel(); + try + { + await observerTask.ConfigureAwait(false); + return ProviderResponseObserverOutcome.Completed; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return ProviderResponseObserverOutcome.Failed; + } + } + + TryCancel(observerCancellation); + ObserveFailure(observerTask); + cancellationToken.ThrowIfCancellationRequested(); + return ProviderResponseObserverOutcome.TimedOut; + } + + private static void TryCancel(CancellationTokenSource source) + { + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { + // The observer completed between the race decision and cancellation. + } + } + + private static void ObserveFailure(Task task) + { + _ = ObserveFailureAsync(task); + } + + private static async Task ObserveFailureAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // A detached observer cannot affect the provider request. + } + } + + private sealed class ObserverState + { + private int active; + + public bool TryEnter() => Interlocked.CompareExchange(ref active, 1, 0) == 0; + + public void Exit() => Volatile.Write(ref active, 0); + } +} diff --git a/src/OpenGameAgent.ProviderTransport/packages.lock.json b/src/OpenGameAgent.ProviderTransport/packages.lock.json new file mode 100644 index 0000000..034482b --- /dev/null +++ b/src/OpenGameAgent.ProviderTransport/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": {} + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.Anthropic/AnthropicMessagesProvider.cs b/src/OpenGameAgent.Providers.Anthropic/AnthropicMessagesProvider.cs new file mode 100644 index 0000000..74ae807 --- /dev/null +++ b/src/OpenGameAgent.Providers.Anthropic/AnthropicMessagesProvider.cs @@ -0,0 +1,984 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.Anthropic; + +public delegate ValueTask AnthropicApiKeyProvider(CancellationToken cancellationToken); + +public enum AnthropicThinkingDisplay +{ + Summarized, + Omitted, +} + +public sealed class AnthropicMessagesProviderOptions +{ + public AnthropicMessagesProviderOptions(HttpClient httpClient, Uri endpoint) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public string? ApiKey { get; set; } + + public AnthropicApiKeyProvider? GetApiKeyAsync { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public string ProviderId { get; set; } = "anthropic"; + + public string ApiId { get; set; } = "anthropic-messages"; + + public string ApiVersion { get; set; } = "2023-06-01"; + + public bool AllowInsecureHttp { get; set; } + + public bool SupportsEagerToolInputStreaming { get; set; } = true; + + public bool SupportsLongCacheRetention { get; set; } = true; + + public bool SendSessionAffinityHeaders { get; set; } + + public bool SupportsCacheControlOnTools { get; set; } = true; + + public bool SupportsTemperature { get; set; } = true; + + public bool ForceAdaptiveThinking { get; set; } + + public bool AllowEmptyThinkingSignature { get; set; } + + public bool SupportsStrictTools { get; set; } + + public bool SupportsToolReferences { get; set; } + + public bool InterleavedThinking { get; set; } = true; + + public AnthropicThinkingDisplay ThinkingDisplay { get; set; } = AnthropicThinkingDisplay.Summarized; + + public int DefaultMaxOutputTokens { get; set; } = 4096; + + public int MaxEventCharacters { get; set; } = 4_000_000; + + public int MaxErrorCharacters { get; set; } = 64_000; + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseCharacters { get; set; } = 16_000_000; + + public int MaxToolCallsPerResponse { get; set; } = 256; +} + +public sealed class AnthropicMessagesProvider : IModelProvider, IModelProviderCapabilities +{ + private const string FineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14"; + private const string InterleavedThinkingBeta = "interleaved-thinking-2025-05-14"; + private readonly AnthropicMessagesProviderOptions _options; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; + private readonly IReadOnlyCollection _supportedApis; + + public AnthropicMessagesProvider(AnthropicMessagesProviderOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + ValidateOptions(options); + _headers = new ReadOnlyDictionary( + new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase)); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + _supportedApis = Array.AsReadOnly(new[] { options.ApiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => _options.SupportsToolReferences; + + public bool SupportsDeferredResponses => false; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("Anthropic Messages uses a server-sent-event transport."); + } + + var apiKey = _options.GetApiKeyAsync is null + ? _options.ApiKey + : await ProviderCallbackRunner.RunAsync( + token => _options.GetApiKeyAsync(token), + cancellationToken) + .ConfigureAwait(false); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint); + ApplyHeaders(httpRequest, apiKey, request); + httpRequest.Content = new ByteArrayContent(SerializeRequest(request)); + httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + + using var response = await _options.HttpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromHttpResponse( + _options.ProviderId, + _options.ApiId, + request.Model, + response), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var error = await ReadBoundedAsync(response.Content, _options.MaxErrorCharacters, cancellationToken) + .ConfigureAwait(false); + var retry = ProviderHttpRetryMetadata.FromResponse(response, errorText: error); + throw new ModelProviderException( + $"The Anthropic endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", + retry.IsTransient, + retry.RetryAfter, + (int)response.StatusCode); + } + + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var cancellationRegistration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var state = new AnthropicStreamState( + request.Model, + _options.ProviderId, + _options.ApiId, + _options.MaxResponseCharacters, + _options.MaxToolCallsPerResponse); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + + await foreach (var serverEvent in ReadSseEventsAsync(reader, _options.MaxEventCharacters, cancellationToken)) + { + if (serverEvent.Data.Length == 0) + { + continue; + } + + foreach (var update in state.Apply(serverEvent.Name, serverEvent.Data)) + { + yield return update; + } + } + + yield return ModelStreamEvent.Terminal(state.Complete()); + } + + private byte[] SerializeRequest(ModelRequest request) + { + var normalizedMessages = ProviderTranscript.Normalize( + request.Messages, + _options.ProviderId, + _options.ApiId, + request.Model, + (id, _, _, _) => NormalizeToolCallId(id)); + var placement = SplitTools(request.Tools, normalizedMessages); + var cacheControl = CacheControl(request.Parameters.CacheRetention); + var payload = new Dictionary + { + ["model"] = request.Model, + ["messages"] = ProjectMessages(normalizedMessages, placement.Deferred, cacheControl), + ["max_tokens"] = request.Parameters.MaxOutputTokens ?? _options.DefaultMaxOutputTokens, + ["stream"] = true, + }; + + if (request.SystemPrompt.Length > 0) + { + var system = new Dictionary + { + ["type"] = "text", + ["text"] = request.SystemPrompt, + }; + if (cacheControl is not null) + { + system["cache_control"] = cacheControl; + } + + payload["system"] = new[] { system }; + } + + if (placement.Immediate.Count > 0 || placement.Deferred.Count > 0) + { + var tools = new List(); + tools.AddRange(ProjectTools(placement.Immediate, cacheControl, deferLoading: false)); + tools.AddRange(ProjectTools(placement.Deferred.Values, null, deferLoading: true)); + payload["tools"] = tools; + } + + ApplyThinking(payload, request.Parameters); + if (request.Parameters.Temperature is { } temperature + && string.IsNullOrWhiteSpace(request.Parameters.ReasoningLevel) + && _options.SupportsTemperature) + { + payload["temperature"] = temperature; + } + + if (request.Parameters.MetadataJson is { } metadataJson) + { + using var metadata = JsonDocument.Parse(metadataJson); + if (metadata.RootElement.TryGetProperty("user_id", out var userId) + && userId.ValueKind == JsonValueKind.String) + { + payload["metadata"] = new Dictionary { ["user_id"] = userId.GetString() }; + } + } + + foreach (var extension in request.Parameters.Extensions) + { + if (payload.ContainsKey(extension.Key)) + { + throw new InvalidOperationException($"Model extension '{extension.Key}' cannot override a core request field."); + } + + payload[extension.Key] = ParseJsonOrString(extension.Value); + } + + if (request.Parameters.SamplingParametersJson is { } sampling) + { + using var document = JsonDocument.Parse(sampling); + foreach (var property in document.RootElement.EnumerateObject()) + { + payload[property.Name] = property.Value.Clone(); + } + } + + var body = JsonSerializer.SerializeToUtf8Bytes(payload); + if (body.Length > _options.MaxRequestBytes) + { + throw new InvalidDataException("The Anthropic request exceeded the configured byte limit."); + } + + return body; + } + + private void ApplyThinking(IDictionary payload, ModelParameters parameters) + { + if (string.IsNullOrWhiteSpace(parameters.ReasoningLevel)) + { + return; + } + + var display = _options.ThinkingDisplay == AnthropicThinkingDisplay.Omitted ? "omitted" : "summarized"; + if (_options.ForceAdaptiveThinking) + { + payload["thinking"] = new Dictionary + { + ["type"] = "adaptive", + ["display"] = display, + }; + payload["output_config"] = new Dictionary + { + ["effort"] = parameters.ReasoningLevel, + }; + return; + } + + var budget = parameters.ReasoningBudgets.TryGetValue(parameters.ReasoningLevel!, out var configured) + ? configured + : 1024; + payload["thinking"] = new Dictionary + { + ["type"] = "enabled", + ["budget_tokens"] = budget, + ["display"] = display, + }; + } + + private IReadOnlyList ProjectMessages( + IReadOnlyList messages, + IReadOnlyDictionary deferredTools, + object? cacheControl) + { + var projected = new List(); + var loadedTools = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < messages.Count; index++) + { + var message = messages[index]; + if (message.Role is AgentRole.User or AgentRole.Custom) + { + AddMessage(projected, "user", ProjectUserBlocks(message)); + continue; + } + + if (message.Role == AgentRole.Assistant) + { + var blocks = new List(); + foreach (var content in message.Content) + { + switch (content) + { + case TextContent text when text.Text.Length > 0: + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = text.Text }); + break; + case ReasoningContent reasoning when reasoning.Redacted: + blocks.Add(new Dictionary + { + ["type"] = "redacted_thinking", + ["data"] = reasoning.Signature, + }); + break; + case ReasoningContent reasoning when !string.IsNullOrWhiteSpace(reasoning.Signature): + blocks.Add(new Dictionary + { + ["type"] = "thinking", + ["thinking"] = reasoning.Text, + ["signature"] = reasoning.Signature, + }); + break; + case ReasoningContent reasoning when _options.AllowEmptyThinkingSignature: + blocks.Add(new Dictionary + { + ["type"] = "thinking", + ["thinking"] = reasoning.Text, + ["signature"] = string.Empty, + }); + break; + case ReasoningContent reasoning when reasoning.Text.Length > 0: + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = reasoning.Text }); + break; + case ToolCallContent call: + blocks.Add(new Dictionary + { + ["type"] = "tool_use", + ["id"] = call.Id, + ["name"] = call.Name, + ["input"] = ParseRequiredObject(call.ArgumentsJson), + }); + break; + } + } + + AddMessage(projected, "assistant", blocks); + continue; + } + + if (message.Role == AgentRole.Tool) + { + var results = new List(); + var sibling = new List(); + while (index < messages.Count && messages[index].Role == AgentRole.Tool) + { + var toolMessage = messages[index]; + var references = toolMessage.AddedToolNames + .Where(name => deferredTools.ContainsKey(name) && loadedTools.Add(name)) + .Select(name => (object)new Dictionary + { + ["type"] = "tool_reference", + ["tool_name"] = name, + }) + .ToArray(); + var ordinary = ProjectToolResultContent(toolMessage.Content); + var result = new Dictionary + { + ["type"] = "tool_result", + ["tool_use_id"] = toolMessage.ToolCallId, + ["content"] = references.Length > 0 ? references : ordinary, + ["is_error"] = toolMessage.IsError, + }; + results.Add(result); + if (references.Length > 0) + { + if (ordinary is string text) + { + sibling.Add(new Dictionary { ["type"] = "text", ["text"] = text }); + } + else if (ordinary is IEnumerable content) + { + sibling.AddRange(content); + } + } + + index++; + } + + index--; + results.AddRange(sibling); + AddMessage(projected, "user", results); + } + } + + if (cacheControl is not null && projected.Count > 0) + { + var lastUser = projected.LastOrDefault(message => message.Role == "user"); + if (lastUser?.Content.LastOrDefault() is Dictionary lastBlock) + { + lastBlock["cache_control"] = cacheControl; + } + } + + return projected.Select(message => (object)new Dictionary + { + ["role"] = message.Role, + ["content"] = message.Content, + }).ToArray(); + } + + private static void AddMessage(List messages, string role, IEnumerable content) + { + var blocks = content.ToArray(); + if (blocks.Length == 0) + { + return; + } + + if (messages.Count > 0 && messages[^1].Role == role) + { + messages[^1].Content.AddRange(blocks); + } + else + { + messages.Add(new MessageProjection(role, blocks)); + } + } + + private static IReadOnlyList ProjectUserBlocks(AgentMessage message) + { + var blocks = new List(); + if (message.Role == AgentRole.Custom) + { + blocks.Add(new Dictionary + { + ["type"] = "text", + ["text"] = "[" + message.CustomRole + "]", + }); + } + + foreach (var content in message.Content) + { + switch (content) + { + case TextContent text when !string.IsNullOrWhiteSpace(text.Text): + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = text.Text }); + break; + case JsonContent json: + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = json.Json }); + break; + case BinaryContent binary when binary.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + blocks.Add(new Dictionary + { + ["type"] = "image", + ["source"] = new Dictionary + { + ["type"] = "base64", + ["media_type"] = binary.MediaType, + ["data"] = binary.Data, + }, + }); + break; + case ResourceContent resource: + blocks.Add(new Dictionary + { + ["type"] = "text", + ["text"] = $"[resource media_type={resource.MediaType}] {resource.Uri}", + }); + break; + case BinaryContent binary: + blocks.Add(new Dictionary + { + ["type"] = "text", + ["text"] = $"[binary media_type={binary.MediaType} data_omitted]", + }); + break; + } + } + + return blocks; + } + + private static object ProjectToolResultContent(IEnumerable content) + { + var blocks = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text: + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = text.Text }); + break; + case JsonContent json: + blocks.Add(new Dictionary { ["type"] = "text", ["text"] = json.Json }); + break; + case BinaryContent binary when binary.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + blocks.Add(new Dictionary + { + ["type"] = "image", + ["source"] = new Dictionary + { + ["type"] = "base64", + ["media_type"] = binary.MediaType, + ["data"] = binary.Data, + }, + }); + break; + } + } + + return blocks.Count switch + { + 0 => "(no tool output)", + 1 when blocks[0] is Dictionary value + && value["type"] as string == "text" => value["text"]!, + _ => blocks, + }; + } + + private IReadOnlyList ProjectTools( + IEnumerable tools, + object? cacheControl, + bool deferLoading) + { + var projected = tools.Select(tool => + { + var strict = tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema; + if (strict + && tool.ConstrainedSampling?.Strictness == ToolSchemaStrictness.Require + && !_options.SupportsStrictTools) + { + throw new InvalidOperationException( + $"Tool '{tool.Name}' requires strict JSON-schema sampling, but the endpoint does not support it."); + } + + using var schemaDocument = JsonDocument.Parse(tool.InputSchemaJson); + var schema = schemaDocument.RootElement; + object inputSchema; + if (strict && _options.SupportsStrictTools) + { + inputSchema = schema.Clone(); + } + else + { + inputSchema = new Dictionary + { + ["type"] = "object", + ["properties"] = schema.TryGetProperty("properties", out var properties) + ? properties.Clone() + : new Dictionary(), + ["required"] = schema.TryGetProperty("required", out var required) + ? required.Clone() + : Array.Empty(), + }; + } + + var value = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["input_schema"] = inputSchema, + }; + if (_options.SupportsEagerToolInputStreaming) + { + value["eager_input_streaming"] = true; + } + + if (strict && _options.SupportsStrictTools) + { + value["strict"] = true; + } + + if (deferLoading) + { + value["defer_loading"] = true; + } + + return value; + }).Cast().ToArray(); + if (cacheControl is not null + && _options.SupportsCacheControlOnTools + && projected.LastOrDefault() is Dictionary last) + { + last["cache_control"] = cacheControl; + } + + return projected; + } + + private ToolPlacement SplitTools( + IReadOnlyList tools, + IReadOnlyList messages) + { + if (!_options.SupportsToolReferences) + { + return new ToolPlacement(tools, new Dictionary(StringComparer.Ordinal)); + } + + var used = messages.Where(message => message.Role == AgentRole.Assistant) + .SelectMany(message => message.Content.OfType()) + .Select(call => call.Name) + .ToHashSet(StringComparer.Ordinal); + var deferredNames = messages.Where(message => message.Role == AgentRole.Tool) + .SelectMany(message => message.AddedToolNames) + .Where(name => !used.Contains(name)) + .ToHashSet(StringComparer.Ordinal); + var unique = tools.GroupBy(tool => tool.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); + var deferred = unique.Where(pair => deferredNames.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var immediate = unique.Where(pair => !deferredNames.Contains(pair.Key)).Select(pair => pair.Value).ToArray(); + if (immediate.Length == 0 && deferred.Count > 0) + { + return new ToolPlacement(deferred.Values.ToArray(), new Dictionary(StringComparer.Ordinal)); + } + + return new ToolPlacement(immediate, deferred); + } + + private object? CacheControl(ModelCacheRetention retention) + { + if (retention == ModelCacheRetention.None) + { + return null; + } + + var value = new Dictionary { ["type"] = "ephemeral" }; + if (retention == ModelCacheRetention.Long && _options.SupportsLongCacheRetention) + { + value["ttl"] = "1h"; + } + + return value; + } + + private void ApplyHeaders(HttpRequestMessage request, string? apiKey, ModelRequest modelRequest) + { + ValidateCredential(apiKey, nameof(AnthropicMessagesProviderOptions.GetApiKeyAsync)); + var suppressed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var header in _headers) + { + request.Headers.Remove(header.Key); + if (header.Value is null) + { + suppressed.Add(header.Key); + } + else + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + if (!request.Headers.Contains("anthropic-version")) + { + request.Headers.TryAddWithoutValidation("anthropic-version", _options.ApiVersion); + } + + var oauth = apiKey?.Contains("sk-ant-oat", StringComparison.Ordinal) == true; + if (!string.IsNullOrEmpty(apiKey)) + { + var credentialHeader = oauth ? "Authorization" : "x-api-key"; + request.Headers.Remove(credentialHeader); + request.Headers.TryAddWithoutValidation(credentialHeader, oauth ? "Bearer " + apiKey : apiKey); + } + + var beta = new List(); + if (!_options.SupportsEagerToolInputStreaming && modelRequest.Tools.Count > 0) + { + beta.Add(FineGrainedToolStreamingBeta); + } + + if (_options.InterleavedThinking && !_options.ForceAdaptiveThinking) + { + beta.Add(InterleavedThinkingBeta); + } + + if (oauth) + { + beta.Insert(0, "oauth-2025-04-20"); + beta.Insert(0, "claude-code-20250219"); + if (!suppressed.Contains("x-app") && !request.Headers.Contains("x-app")) + { + request.Headers.TryAddWithoutValidation("x-app", "cli"); + } + } + + if (beta.Count > 0 && !request.Headers.Contains("anthropic-beta")) + { + request.Headers.TryAddWithoutValidation("anthropic-beta", string.Join(",", beta)); + } + + if (_options.SendSessionAffinityHeaders + && modelRequest.Parameters.CacheRetention != ModelCacheRetention.None + && modelRequest.SessionId is { } sessionId + && !suppressed.Contains("x-session-affinity") + && !request.Headers.Contains("x-session-affinity")) + { + request.Headers.TryAddWithoutValidation("x-session-affinity", sessionId); + } + } + + private static string NormalizeToolCallId(string value) + { + var builder = new StringBuilder(Math.Min(value.Length, 64)); + foreach (var character in value) + { + if (builder.Length == 64) + { + break; + } + + builder.Append(char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_'); + } + + return builder.Length == 0 ? "tool_call" : builder.ToString(); + } + + private static JsonElement ParseRequiredObject(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Tool arguments must be a JSON object."); + } + + return document.RootElement.Clone(); + } + + private static object? ParseJsonOrString(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return value; + } + } + + private static void ValidateOptions(AnthropicMessagesProviderOptions options) + { + if (!options.Endpoint.IsAbsoluteUri + || options.Endpoint.UserInfo.Length > 0 + || (options.Endpoint.Scheme != Uri.UriSchemeHttp && options.Endpoint.Scheme != Uri.UriSchemeHttps) + || (options.Endpoint.Scheme == Uri.UriSchemeHttp && !options.Endpoint.IsLoopback && !options.AllowInsecureHttp)) + { + throw new ArgumentException("The endpoint must be a permitted absolute HTTP or HTTPS URI without credentials.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ProviderId) + || string.IsNullOrWhiteSpace(options.ApiId) + || string.IsNullOrWhiteSpace(options.ApiVersion) + || !Enum.IsDefined(typeof(AnthropicThinkingDisplay), options.ThinkingDisplay) + || options.DefaultMaxOutputTokens < 1 + || options.MaxEventCharacters is < 1 or > 100_000_000 + || options.MaxErrorCharacters is < 1 or > 10_000_000 + || options.MaxRequestBytes is < 2 or > 100_000_000 + || options.MaxResponseCharacters is < 1 or > 100_000_000 + || options.MaxToolCallsPerResponse is < 1 or > 10_000 + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentException("One or more Anthropic provider identifiers or bounds are invalid.", nameof(options)); + } + + ValidateCredential(options.ApiKey, nameof(options)); + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); + } + + private static void ValidateCredential(string? value, string parameterName) + { + if ((value?.Length ?? 0) > 65_536 + || (value is { Length: > 0 } && string.IsNullOrWhiteSpace(value)) + || value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A credential is empty, too large, or contains invalid control characters.", parameterName); + } + } + + private static async Task ReadBoundedAsync( + HttpContent content, + int maximumCharacters, + CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var buffer = new char[Math.Min(4096, maximumCharacters)]; + var builder = new StringBuilder(); + while (builder.Length < maximumCharacters) + { + var read = await reader.ReadAsync(buffer, 0, Math.Min(buffer.Length, maximumCharacters - builder.Length)) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + builder.Append(buffer, 0, read); + } + + return builder.ToString(); + } + + private static async IAsyncEnumerable ReadSseEventsAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var eventName = string.Empty; + var data = new StringBuilder(); + await foreach (var line in ReadBoundedLinesAsync(reader, maximumCharacters, cancellationToken)) + { + if (line.Length == 0) + { + if (data.Length > 0) + { + yield return new SseEvent(eventName, data.ToString()); + } + + eventName = string.Empty; + data.Clear(); + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line.Substring(6).TrimStart(); + } + else if (line.StartsWith("data:", StringComparison.Ordinal)) + { + if (data.Length > 0) + { + data.Append('\n'); + } + + data.Append(line.Substring(5).TrimStart()); + if (data.Length > maximumCharacters) + { + throw new InvalidDataException("An Anthropic SSE event exceeded the configured size limit."); + } + } + } + + if (data.Length > 0) + { + yield return new SseEvent(eventName, data.ToString()); + } + } + + private static async IAsyncEnumerable ReadBoundedLinesAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(Math.Min(4096, maximumCharacters + 1)); + var line = new StringBuilder(); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (read == 0) + { + if (line.Length > 0) + { + yield return TrimCarriageReturn(line); + } + + yield break; + } + + for (var index = 0; index < read; index++) + { + if (buffer[index] == '\n') + { + yield return TrimCarriageReturn(line); + line.Clear(); + } + else + { + line.Append(buffer[index]); + if (line.Length > maximumCharacters) + { + throw new InvalidDataException("An Anthropic SSE line exceeded the configured size limit."); + } + } + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string TrimCarriageReturn(StringBuilder line) + { + var length = line.Length; + if (length > 0 && line[length - 1] == '\r') + { + length--; + } + + return line.ToString(0, length); + } + + private sealed class MessageProjection + { + public MessageProjection(string role, IEnumerable content) + { + Role = role; + Content = content.ToList(); + } + + public string Role { get; } + + public List Content { get; } + } + + private sealed class ToolPlacement + { + public ToolPlacement( + IReadOnlyList immediate, + IReadOnlyDictionary deferred) + { + Immediate = immediate; + Deferred = deferred; + } + + public IReadOnlyList Immediate { get; } + + public IReadOnlyDictionary Deferred { get; } + } + + private sealed class SseEvent + { + public SseEvent(string name, string data) + { + Name = name; + Data = data; + } + + public string Name { get; } + + public string Data { get; } + } +} diff --git a/src/OpenGameAgent.Providers.Anthropic/AnthropicStreamState.cs b/src/OpenGameAgent.Providers.Anthropic/AnthropicStreamState.cs new file mode 100644 index 0000000..49afff7 --- /dev/null +++ b/src/OpenGameAgent.Providers.Anthropic/AnthropicStreamState.cs @@ -0,0 +1,583 @@ +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Anthropic; + +internal sealed class AnthropicStreamState +{ + private readonly string _requestModel; + private readonly string _providerId; + private readonly string _apiId; + private readonly int _maximumCharacters; + private readonly int _maximumToolCalls; + private readonly SortedDictionary _blocks = new(); + private long _characters; + private string? _responseId; + private string? _responseModel; + private ModelStopReason _stopReason = ModelStopReason.Pending; + private string? _rawStopReason; + private string? _errorMessage; + private ModelUsage _usage = new(); + private bool _messageStarted; + private bool _messageStopped; + + public AnthropicStreamState( + string requestModel, + string providerId, + string apiId, + int maximumCharacters, + int maximumToolCalls) + { + _requestModel = requestModel; + _providerId = providerId; + _apiId = apiId; + _maximumCharacters = maximumCharacters; + _maximumToolCalls = maximumToolCalls; + } + + public IReadOnlyList Apply(string eventName, string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + RequireKind(root, JsonValueKind.Object, "An Anthropic event must be a JSON object."); + EnsureUnambiguous(root); + var type = RequiredString(root, "type"); + if (!string.IsNullOrEmpty(eventName) + && eventName != "ping" + && !string.Equals(eventName, type, StringComparison.Ordinal)) + { + throw new InvalidDataException("The Anthropic SSE event name does not match its JSON event type."); + } + + var updates = new List(); + switch (type) + { + case "ping": + break; + case "message_start": + StartMessage(RequiredObject(root, "message")); + break; + case "content_block_start": + StartBlock(RequiredIndex(root), RequiredObject(root, "content_block"), updates); + break; + case "content_block_delta": + ApplyDelta(RequiredIndex(root), RequiredObject(root, "delta"), updates); + break; + case "content_block_stop": + StopBlock(RequiredIndex(root), updates); + break; + case "message_delta": + ApplyMessageDelta(root); + break; + case "message_stop": + if (!_messageStarted || _messageStopped) + { + throw new InvalidDataException("The Anthropic stream stopped a missing or already stopped message."); + } + + _messageStopped = true; + break; + case "error": + var error = RequiredObject(root, "error"); + throw new InvalidDataException( + $"Anthropic stream error {OptionalString(error, "type") ?? "unknown"}: " + + (OptionalString(error, "message") ?? "No message was supplied.")); + default: + throw new InvalidDataException("The Anthropic stream returned unsupported event type '" + type + "'."); + } + + return updates; + } + catch (JsonException exception) + { + throw new InvalidDataException("The Anthropic stream contained invalid JSON.", exception); + } + catch (InvalidOperationException exception) + { + throw new InvalidDataException("The Anthropic stream did not match the expected response shape.", exception); + } + } + + public ModelResponse Partial() => BuildResponse(ModelStopReason.Pending, null); + + public ModelResponse Complete() + { + if (!_messageStopped) + { + throw new InvalidDataException("The Anthropic stream ended before message_stop."); + } + + if (_stopReason == ModelStopReason.Pending) + { + throw new InvalidDataException("The Anthropic stream ended without a stop reason."); + } + + if (_blocks.Values.Any(block => !block.Ended)) + { + throw new InvalidDataException("The Anthropic stream ended with an incomplete content block."); + } + + return BuildResponse(_stopReason, _errorMessage); + } + + private ModelResponse BuildResponse(ModelStopReason reason, string? errorMessage) + { + var content = new List(); + foreach (var block in _blocks.Values) + { + switch (block.Kind) + { + case BlockKind.Text: + content.Add(new TextContent(block.Buffer.ToString())); + break; + case BlockKind.Thinking: + content.Add(new ReasoningContent(block.Buffer.ToString(), block.Signature.ToString())); + break; + case BlockKind.RedactedThinking: + content.Add(new ReasoningContent("[Reasoning redacted]", block.Signature.ToString(), redacted: true)); + break; + case BlockKind.Tool: + content.Add(CreateToolCall(block, reason)); + break; + } + } + + return new ModelResponse( + content, + reason, + _usage, + errorMessage, + _providerId, + _apiId, + _responseModel ?? _requestModel, + _responseId, + _rawStopReason); + } + + private void StartMessage(JsonElement message) + { + if (_messageStarted) + { + throw new InvalidDataException("The Anthropic stream started more than one message."); + } + + _messageStarted = true; + _responseId = RequiredString(message, "id"); + _responseModel = OptionalString(message, "model") ?? _requestModel; + if (message.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + ReadUsage(usage); + } + } + + private void StartBlock(int index, JsonElement content, ICollection updates) + { + if (!_messageStarted || _messageStopped || _blocks.ContainsKey(index)) + { + throw new InvalidDataException("An Anthropic content block started in an invalid state."); + } + + var type = RequiredString(content, "type"); + Block? block = type switch + { + "text" => new Block(index, BlockKind.Text), + "thinking" => new Block(index, BlockKind.Thinking), + "redacted_thinking" => new Block(index, BlockKind.RedactedThinking), + "tool_use" => CreateToolBlock(index, content), + _ => null, + }; + if (block is null) + { + return; + } + + if (block.Kind == BlockKind.Tool + && _blocks.Values.Count(value => value.Kind == BlockKind.Tool) >= _maximumToolCalls) + { + throw new InvalidDataException("The Anthropic response exceeded the configured tool-call limit."); + } + + if (block.Kind == BlockKind.Text) + { + Append(block.Buffer, OptionalString(content, "text") ?? string.Empty); + } + else if (block.Kind == BlockKind.Thinking) + { + Append(block.Buffer, OptionalString(content, "thinking") ?? string.Empty); + Append(block.Signature, OptionalString(content, "signature") ?? string.Empty); + } + else if (block.Kind == BlockKind.RedactedThinking) + { + Append(block.Signature, RequiredString(content, "data")); + } + + _blocks.Add(index, block); + var kind = block.Kind switch + { + BlockKind.Text => ModelStreamEventKind.TextStarted, + BlockKind.Tool => ModelStreamEventKind.ToolCallStarted, + _ => ModelStreamEventKind.ReasoningStarted, + }; + updates.Add(ModelStreamEvent.Update( + kind, + Partial(), + contentIndex: ContentIndex(index), + toolCallId: block.Id, + toolName: block.Name)); + } + + private static Block CreateToolBlock(int index, JsonElement content) + { + var block = new Block(index, BlockKind.Tool) + { + Id = RequiredString(content, "id"), + Name = RequiredString(content, "name"), + }; + if (content.TryGetProperty("input", out var input)) + { + if (input.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Anthropic tool input must be a JSON object."); + } + + block.InitialInput = input.GetRawText(); + } + + return block; + } + + private void ApplyDelta(int index, JsonElement delta, ICollection updates) + { + if (!_blocks.TryGetValue(index, out var block) || block.Ended) + { + throw new InvalidDataException("An Anthropic delta referenced a missing or ended content block."); + } + + var type = RequiredString(delta, "type"); + switch (type) + { + case "text_delta" when block.Kind == BlockKind.Text: + AddVisibleDelta(block, RequiredString(delta, "text"), ModelStreamEventKind.TextDelta, updates); + break; + case "thinking_delta" when block.Kind == BlockKind.Thinking: + AddVisibleDelta(block, RequiredString(delta, "thinking"), ModelStreamEventKind.ReasoningDelta, updates); + break; + case "input_json_delta" when block.Kind == BlockKind.Tool: + AddVisibleDelta(block, RequiredString(delta, "partial_json"), ModelStreamEventKind.ToolCallDelta, updates); + break; + case "signature_delta" when block.Kind == BlockKind.Thinking: + Append(block.Signature, RequiredString(delta, "signature")); + break; + default: + throw new InvalidDataException("An Anthropic content delta did not match its block type."); + } + } + + private void AddVisibleDelta( + Block block, + string delta, + ModelStreamEventKind kind, + ICollection updates) + { + Append(block.Buffer, delta); + updates.Add(ModelStreamEvent.Update( + kind, + Partial(), + delta, + ContentIndex(block.Index), + block.Id, + block.Name)); + } + + private void StopBlock(int index, ICollection updates) + { + if (!_blocks.TryGetValue(index, out var block) || block.Ended) + { + throw new InvalidDataException("An Anthropic content block stopped in an invalid state."); + } + + block.Ended = true; + var kind = block.Kind switch + { + BlockKind.Text => ModelStreamEventKind.TextEnded, + BlockKind.Tool => ModelStreamEventKind.ToolCallEnded, + _ => ModelStreamEventKind.ReasoningEnded, + }; + var contentIndex = ContentIndex(index); + var partial = Partial(); + var toolCall = kind == ModelStreamEventKind.ToolCallEnded + ? CreateToolCall(block, ModelStopReason.Pending) + : null; + updates.Add(ModelStreamEvent.Update( + kind, + partial, + contentIndex: contentIndex, + toolCallId: block.Id, + toolName: block.Name, + toolCall: toolCall, + content: kind switch + { + ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded when block.Kind == BlockKind.RedactedThinking => + "[Reasoning redacted]", + ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded => block.Buffer.ToString(), + _ => null, + })); + } + + private static ToolCallContent CreateToolCall(Block block, ModelStopReason reason) + { + var arguments = block.Buffer.Length > 0 ? block.Buffer.ToString() : block.InitialInput; + if (string.IsNullOrWhiteSpace(arguments)) + { + arguments = "{}"; + } + + if (reason is ModelStopReason.Pending or ModelStopReason.Length) + { + arguments = StreamingJson.ParseObject(arguments); + } + + if (!IsJsonObject(arguments)) + { + throw new InvalidDataException("A completed Anthropic tool call did not contain a JSON object."); + } + + return new ToolCallContent(block.Id!, block.Name!, arguments); + } + + private void ApplyMessageDelta(JsonElement root) + { + var delta = RequiredObject(root, "delta"); + if (delta.TryGetProperty("stop_reason", out var reason) && reason.ValueKind != JsonValueKind.Null) + { + if (reason.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("Anthropic stop_reason must be a string or null."); + } + + _rawStopReason = reason.GetString(); + MapStopReason(_rawStopReason!, delta); + } + + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + ReadUsage(usage); + } + } + + private void MapStopReason(string reason, JsonElement delta) + { + switch (reason) + { + case "end_turn": + case "pause_turn": + case "stop_sequence": + _stopReason = ModelStopReason.Stop; + break; + case "max_tokens": + _stopReason = ModelStopReason.Length; + break; + case "tool_use": + _stopReason = ModelStopReason.ToolUse; + break; + case "refusal": + _stopReason = ModelStopReason.Error; + _errorMessage = ReadStopExplanation(delta) ?? "The model refused to complete the request."; + break; + case "sensitive": + _stopReason = ModelStopReason.Error; + _errorMessage = "The provider stopped the response because it was marked sensitive."; + break; + default: + _stopReason = ModelStopReason.Error; + _errorMessage = "The provider returned unsupported stop reason '" + reason + "'."; + break; + } + } + + private static string? ReadStopExplanation(JsonElement delta) + { + if (!delta.TryGetProperty("stop_details", out var details) || details.ValueKind != JsonValueKind.Object) + { + return null; + } + + return OptionalString(details, "explanation"); + } + + private void ReadUsage(JsonElement usage) + { + var input = OptionalNonNegativeLong(usage, "input_tokens") ?? _usage.InputTokens; + var output = OptionalNonNegativeLong(usage, "output_tokens") ?? _usage.OutputTokens; + var cacheRead = OptionalNonNegativeLong(usage, "cache_read_input_tokens") ?? _usage.CacheReadTokens; + var cacheWrite = OptionalNonNegativeLong(usage, "cache_creation_input_tokens") ?? _usage.CacheWriteTokens; + var oneHour = _usage.CacheWriteOneHourTokens; + if (usage.TryGetProperty("cache_creation", out var cacheCreation) + && cacheCreation.ValueKind == JsonValueKind.Object) + { + oneHour = OptionalNonNegativeLong(cacheCreation, "ephemeral_1h_input_tokens") ?? oneHour; + } + + var reasoning = _usage.ReasoningTokens; + if (usage.TryGetProperty("output_tokens_details", out var details) + && details.ValueKind == JsonValueKind.Object) + { + reasoning = OptionalNonNegativeLong(details, "thinking_tokens") ?? reasoning; + } + + if (reasoning > output || oneHour > cacheWrite) + { + throw new InvalidDataException("Anthropic usage contains inconsistent token subsets."); + } + + _usage = new ModelUsage(input, output, cacheRead, cacheWrite, reasoning, oneHour); + } + + private int ContentIndex(int blockIndex) => _blocks.Keys.TakeWhile(key => key != blockIndex).Count(); + + private void Append(StringBuilder builder, string value) + { + _characters += value.Length; + if (_characters > _maximumCharacters) + { + throw new InvalidDataException("The accumulated Anthropic response exceeded the configured size limit."); + } + + builder.Append(value); + } + + private static int RequiredIndex(JsonElement root) + { + if (!root.TryGetProperty("index", out var value) || !value.TryGetInt32(out var index) || index < 0) + { + throw new InvalidDataException("An Anthropic content index must be a non-negative integer."); + } + + return index; + } + + private static JsonElement RequiredObject(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException($"Anthropic event field '{property}' must be an object."); + } + + return value; + } + + private static string RequiredString(JsonElement root, string property) => + OptionalString(root, property) + ?? throw new InvalidDataException($"Anthropic event field '{property}' must be a string."); + + private static string? OptionalString(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException($"Anthropic event field '{property}' must be a string or null."); + } + + return value.GetString(); + } + + private static long? OptionalNonNegativeLong(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (!value.TryGetInt64(out var result) || result < 0) + { + throw new InvalidDataException($"Anthropic usage field '{property}' must be a non-negative integer."); + } + + return result; + } + + private static bool IsJsonObject(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static void RequireKind(JsonElement value, JsonValueKind expected, string message) + { + if (value.ValueKind != expected) + { + throw new InvalidDataException(message); + } + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("The Anthropic stream contains duplicate JSON property names."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private enum BlockKind + { + Text, + Thinking, + RedactedThinking, + Tool, + } + + private sealed class Block + { + public Block(int index, BlockKind kind) + { + Index = index; + Kind = kind; + } + + public int Index { get; } + + public BlockKind Kind { get; } + + public StringBuilder Buffer { get; } = new(); + + public StringBuilder Signature { get; } = new(); + + public string? InitialInput { get; set; } + + public string? Id { get; set; } + + public string? Name { get; set; } + + public bool Ended { get; set; } + } +} diff --git a/src/OpenGameAgent.Providers.Anthropic/OpenGameAgent.Providers.Anthropic.csproj b/src/OpenGameAgent.Providers.Anthropic/OpenGameAgent.Providers.Anthropic.csproj new file mode 100644 index 0000000..558d2f1 --- /dev/null +++ b/src/OpenGameAgent.Providers.Anthropic/OpenGameAgent.Providers.Anthropic.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + OpenGameAgent.Providers.Anthropic + Native Anthropic Messages transport for OpenGameAgent. + + + + + + + + + diff --git a/src/OpenGameAgent.Providers.Anthropic/packages.lock.json b/src/OpenGameAgent.Providers.Anthropic/packages.lock.json new file mode 100644 index 0000000..775e1fc --- /dev/null +++ b/src/OpenGameAgent.Providers.Anthropic/packages.lock.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.Bedrock/AssemblyInfo.cs b/src/OpenGameAgent.Providers.Bedrock/AssemblyInfo.cs new file mode 100644 index 0000000..f763669 --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenGameAgent.Providers.Bedrock.Tests")] diff --git a/src/OpenGameAgent.Providers.Bedrock/AwsBedrockTransport.cs b/src/OpenGameAgent.Providers.Bedrock/AwsBedrockTransport.cs new file mode 100644 index 0000000..5513773 --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/AwsBedrockTransport.cs @@ -0,0 +1,409 @@ +using System.Runtime.CompilerServices; +using Amazon.BedrockRuntime; +using Amazon.BedrockRuntime.Model; +using Amazon.Runtime; +using Amazon.Runtime.EventStreams; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.Bedrock; + +internal static class AwsBedrockTransport +{ + public static async IAsyncEnumerable StreamAsync( + IAmazonBedrockRuntime client, + ConverseStreamRequest request, + IReadOnlyDictionary headers, + string providerId, + string apiId, + string model, + ProviderResponseObserver? responseObserver, + int responseObserverTimeoutMilliseconds, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + RequestEventHandler? requestHandler = null; + var serviceClient = client as AmazonServiceClient; + IDisposable? clientRequestLease = null; + if (serviceClient is not null) + { + clientRequestLease = await BedrockClientRequestGate.EnterAsync(serviceClient, cancellationToken) + .ConfigureAwait(false); + } + + if (serviceClient is not null && headers.Count > 0) + { + requestHandler = (_, args) => + { + if (args is HeadersRequestEventArgs headerArgs) + { + ApplyHeaders(headerArgs.Headers, headers); + } + }; + serviceClient.BeforeRequestEvent += requestHandler; + } + + ConverseStreamResponse response; + try + { + response = await client.ConverseStreamAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + await ObserveFailureAsync( + exception, + providerId, + apiId, + model, + responseObserver, + responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + throw CreateProviderFailure(exception, null, null); + } + finally + { + if (requestHandler is not null) + { + serviceClient!.BeforeRequestEvent -= requestHandler; + } + + clientRequestLease?.Dispose(); + } + + using (response) + { + var responseRequestId = response.ResponseMetadata?.RequestId; + var responseStatus = (int)response.HttpStatusCode; + await ObserveResponseAsync( + providerId, + apiId, + model, + responseStatus, + responseRequestId, + responseObserver, + responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + + var stream = response.Stream ?? throw new InvalidDataException("Bedrock returned no response stream."); + var queue = new Queue(); + var signal = new SemaphoreSlim(0); + Exception? streamException = null; + + void Enqueue(BedrockProtocolEvent value) + { + lock (queue) + { + queue.Enqueue(value); + } + + signal.Release(); + } + + stream.MessageStartReceived += (_, args) => + Enqueue(BedrockProtocolEvent.MessageStart(args.EventStreamEvent.Role?.Value ?? string.Empty)); + stream.ContentBlockStartReceived += (_, args) => + { + var item = args.EventStreamEvent; + Enqueue(BedrockProtocolEvent.ContentStart( + item.ContentBlockIndex ?? 0, + item.Start?.ToolUse?.ToolUseId, + item.Start?.ToolUse?.Name)); + }; + stream.ContentBlockDeltaReceived += (_, args) => + { + var item = args.EventStreamEvent; + var index = item.ContentBlockIndex ?? 0; + if (item.Delta?.Text is { } text) + { + Enqueue(BedrockProtocolEvent.TextDelta(index, text)); + } + else if (item.Delta?.ToolUse?.Input is { } input) + { + Enqueue(BedrockProtocolEvent.ToolDelta(index, input)); + } + else if (item.Delta?.ReasoningContent is { } reasoning) + { + Enqueue(BedrockProtocolEvent.ReasoningDelta(index, reasoning.Text, reasoning.Signature)); + } + }; + stream.ContentBlockStopReceived += (_, args) => + Enqueue(BedrockProtocolEvent.ContentStop(args.EventStreamEvent.ContentBlockIndex ?? 0)); + stream.MessageStopReceived += (_, args) => + Enqueue(BedrockProtocolEvent.MessageStop(args.EventStreamEvent.StopReason?.Value ?? string.Empty)); + stream.MetadataReceived += (_, args) => + { + var usage = args.EventStreamEvent.Usage; + if (usage is not null) + { + Enqueue(BedrockProtocolEvent.Usage( + usage.InputTokens ?? 0, + usage.OutputTokens ?? 0, + usage.CacheReadInputTokens ?? 0, + usage.CacheWriteInputTokens ?? 0)); + } + }; + stream.ExceptionReceived += (_, args) => + { + streamException = args.EventStreamException; + signal.Release(); + }; + + Task processing; + try + { + processing = stream.StartProcessingAsync(); + } + catch (Exception exception) + { + throw CreateProviderFailure(exception, responseRequestId, responseStatus); + } + + while (true) + { + while (TryDequeue(queue, out var item)) + { + yield return item!; + } + + if (processing.IsCompleted) + { + break; + } + + await Task.WhenAny(signal.WaitAsync(cancellationToken), processing).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (streamException is not null) + { + throw CreateProviderFailure(streamException, responseRequestId, responseStatus); + } + } + + try + { + await processing.ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw CreateProviderFailure(exception, responseRequestId, responseStatus); + } + + if (streamException is not null) + { + throw CreateProviderFailure(streamException, responseRequestId, responseStatus); + } + + while (TryDequeue(queue, out var finalItem)) + { + yield return finalItem!; + } + } + } + + internal static ModelProviderException CreateProviderFailure( + Exception exception, + string? responseRequestId, + int? responseStatus) + { + if (exception is ModelProviderException providerException) + { + return providerException; + } + + var service = exception as AmazonServiceException; + var status = service is not null && (int)service.StatusCode > 0 + ? (int)service.StatusCode + : responseStatus is > 0 and not 200 ? responseStatus : null; + var requestId = Bound(service?.RequestId ?? responseRequestId, 1024); + var errorCode = Bound(service?.ErrorCode, 256); + if (string.Equals(errorCode, "Unknown", StringComparison.OrdinalIgnoreCase)) + { + errorCode = null; + } + + if (service is null + && errorCode is null + && exception.GetType() != typeof(Exception) + && exception.GetType().Name.EndsWith("Exception", StringComparison.Ordinal) + && exception is not IOException) + { + errorCode = Bound(exception.GetType().Name, 256); + } + + var data = new Dictionary(); + if (status is not null) + { + data["status"] = status; + } + + if (errorCode is not null) + { + data["errorCode"] = errorCode; + } + + if (requestId is not null) + { + data["requestId"] = requestId; + } + + var diagnostics = data.Count == 0 + ? Array.Empty() + : new[] + { + new ModelDiagnostic( + "bedrock_response_failure", + "The provider returned structured failure metadata.", + ModelDiagnosticSeverity.Error, + System.Text.Json.JsonSerializer.Serialize(data)), + }; + var retryableByProvider = service is null + ? IsKnownTransientLocalFailure(exception) + : service.Retryable is not null || IsRetryableErrorCode(errorCode) + ? true + : (bool?)null; + var retry = ProviderHttpRetryMetadata.FromStatus(status, retryableByProvider); + return new ModelProviderException( + exception.Message, + diagnostics, + retry.IsTransient, + retry.RetryAfter, + status, + exception); + } + + private static async ValueTask ObserveFailureAsync( + Exception exception, + string providerId, + string apiId, + string model, + ProviderResponseObserver? responseObserver, + int responseObserverTimeoutMilliseconds, + CancellationToken cancellationToken) + { + var service = exception as AmazonServiceException; + var status = service is null ? 0 : (int)service.StatusCode; + if (status is < 100 or > 599) + { + return; + } + + await ObserveResponseAsync( + providerId, + apiId, + model, + status, + service!.RequestId, + responseObserver, + responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + } + + internal static ValueTask ObserveResponseAsync( + string providerId, + string apiId, + string model, + int statusCode, + string? requestId, + ProviderResponseObserver? responseObserver, + int responseObserverTimeoutMilliseconds, + CancellationToken cancellationToken) + { + if (statusCode is < 100 or > 599) + { + return new ValueTask(ProviderResponseObserverOutcome.NotConfigured); + } + + return ProviderResponseObserverRunner.NotifyAsync( + responseObserver, + ProviderResponseObservation.FromProviderResponse( + providerId, + apiId, + model, + statusCode, + requestId), + responseObserverTimeoutMilliseconds, + cancellationToken); + } + + private static bool IsRetryableErrorCode(string? errorCode) => + errorCode is not null + && (errorCode.Contains("throttl", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("ModelNotReadyException", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("ModelTimeoutException", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("ServiceUnavailableException", StringComparison.OrdinalIgnoreCase) + || errorCode.Equals("InternalServerException", StringComparison.OrdinalIgnoreCase)); + + private static bool IsKnownTransientLocalFailure(Exception exception) => + exception is IOException or HttpRequestException or TimeoutException or TaskCanceledException; + + private static string? Bound(string? value, int maximumLength) => + string.IsNullOrWhiteSpace(value) || value.Length > maximumLength ? null : value; + + internal static void ApplyHeaders( + IDictionary requestHeaders, + IReadOnlyDictionary customHeaders) + { + foreach (var pair in customHeaders) + { + requestHeaders[pair.Key] = pair.Value; + } + } + + private static bool TryDequeue(Queue queue, out BedrockProtocolEvent? value) + { + lock (queue) + { + if (queue.Count > 0) + { + value = queue.Dequeue(); + return true; + } + } + + value = null; + return false; + } +} + +internal static class BedrockClientRequestGate +{ + private static readonly ConditionalWeakTable Gates = new(); + + public static async ValueTask EnterAsync( + object client, + CancellationToken cancellationToken) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + var gate = Gates.GetValue(client, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Lease(gate); + } + + private sealed class Lease : IDisposable + { + private SemaphoreSlim? gate; + + public Lease(SemaphoreSlim gate) + { + this.gate = gate; + } + + public void Dispose() + { + Interlocked.Exchange(ref gate, null)?.Release(); + } + } +} diff --git a/src/OpenGameAgent.Providers.Bedrock/BedrockConverseProvider.cs b/src/OpenGameAgent.Providers.Bedrock/BedrockConverseProvider.cs new file mode 100644 index 0000000..8dc2f7a --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/BedrockConverseProvider.cs @@ -0,0 +1,882 @@ +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Amazon; +using Amazon.BedrockRuntime; +using Amazon.BedrockRuntime.Model; +using Amazon.Runtime; +using Amazon.Runtime.CredentialManagement; +using Amazon.Runtime.Documents; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.Bedrock; + +public enum BedrockToolChoice +{ + Auto, + Any, + None, + Tool, +} + +public enum BedrockThinkingDisplay +{ + Summarized, + Omitted, +} + +public sealed class BedrockConverseProviderOptions +{ + public IAmazonBedrockRuntime? Client { get; set; } + + public BedrockConverseTransport? Transport { get; set; } + + public string ProviderId { get; set; } = "amazon-bedrock"; + + public string ApiId { get; set; } = "bedrock-converse-stream"; + + public string? Region { get; set; } + + public string? Profile { get; set; } + + public string? ServiceUrl { get; set; } + + public bool AllowInsecureHttp { get; set; } + + public string? AccessKeyId { get; set; } + + public string? SecretAccessKey { get; set; } + + public string? SessionToken { get; set; } + + public string? BearerToken { get; set; } + + public Func? ModelDisplayNameResolver { get; set; } + + public bool SkipAuthentication { get; set; } + + public bool SupportsStrictTools { get; set; } + + public bool ForcePromptCaching { get; set; } + + public BedrockToolChoice? ToolChoice { get; set; } + + public string? RequiredToolName { get; set; } + + public bool InterleavedThinking { get; set; } = true; + + public BedrockThinkingDisplay ThinkingDisplay { get; set; } = BedrockThinkingDisplay.Summarized; + + public IDictionary RequestMetadata { get; } = + new Dictionary(StringComparer.Ordinal); + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public int MaxResponseCharacters { get; set; } = 16_000_000; + + public int MaxToolCallsPerResponse { get; set; } = 256; +} + +public sealed class BedrockConverseProvider : IModelProvider, IModelProviderCapabilities +{ + private const string EmptyText = ""; + private readonly BedrockConverseProviderOptions _options; + private readonly IReadOnlyDictionary _requestMetadata; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; + private readonly IReadOnlyCollection _supportedApis; + + public BedrockConverseProvider(BedrockConverseProviderOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + ValidateOptions(options); + _requestMetadata = new ReadOnlyDictionary( + new Dictionary(options.RequestMetadata, StringComparer.Ordinal)); + _headers = NormalizeHeaders(options.Headers); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + _supportedApis = Array.AsReadOnly(new[] { options.ApiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => false; + + public bool SupportsDeferredResponses => false; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("Bedrock ConverseStream uses the AWS event-stream transport."); + } + + var protocolRequest = BuildRequest(request); + var state = new BedrockStreamState( + request.Model, + _options.ProviderId, + _options.ApiId, + _options.MaxResponseCharacters, + _options.MaxToolCallsPerResponse); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + + IAmazonBedrockRuntime? ownedClient = null; + var transport = _options.Transport; + if (transport is null) + { + var client = _options.Client; + if (client is null) + { + client = CreateClient(request.Model); + ownedClient = client; + } + + transport = (value, token) => AwsBedrockTransport.StreamAsync( + client, + value, + _headers, + _options.ProviderId, + _options.ApiId, + request.Model, + _responseObserver, + _responseObserverTimeoutMilliseconds, + token); + } + + try + { + await foreach (var item in transport(protocolRequest, cancellationToken).WithCancellation(cancellationToken)) + { + foreach (var update in state.Apply(item)) + { + yield return update; + } + } + + yield return ModelStreamEvent.Terminal(state.Complete()); + } + finally + { + ownedClient?.Dispose(); + } + } + + internal ConverseStreamRequest BuildRequest(ModelRequest request) + { + var modelIdentity = string.Join( + " ", + new[] { request.Model, _options.ModelDisplayNameResolver?.Invoke(request.Model) } + .Where(value => !string.IsNullOrWhiteSpace(value))); + var messages = ProviderTranscript.Normalize( + request.Messages, + _options.ProviderId, + _options.ApiId, + request.Model, + (id, _, _, _) => NormalizeToolCallId(id)); + var cache = request.Parameters.CacheRetention != ModelCacheRetention.None && SupportsPromptCaching(modelIdentity); + var additionalFields = BuildAdditionalFields(modelIdentity, request.Parameters); + var result = new ConverseStreamRequest + { + ModelId = request.Model, + Messages = ProjectMessages(messages, modelIdentity, cache, request.Parameters.CacheRetention), + InferenceConfig = BuildInferenceConfig(request.Parameters), + System = BuildSystem(request.SystemPrompt, cache, request.Parameters.CacheRetention), + ToolConfig = BuildToolConfiguration(request.Tools), + RequestMetadata = _requestMetadata.Count > 0 + ? new Dictionary(_requestMetadata, StringComparer.Ordinal) + : null, + }; + if (additionalFields.HasValue) + { + result.AdditionalModelRequestFields = additionalFields.Value; + } + + return result; + } + + private List ProjectMessages( + IReadOnlyList messages, + string model, + bool cache, + ModelCacheRetention retention) + { + var result = new List(); + for (var index = 0; index < messages.Count; index++) + { + var message = messages[index]; + if (message.Role is AgentRole.User or AgentRole.Custom) + { + result.Add(new Message + { + Role = ConversationRole.User, + Content = ProjectUserContent(message.Content), + }); + continue; + } + + if (message.Role == AgentRole.Assistant) + { + var content = ProjectAssistantContent(message.Content, model); + if (content.Count > 0) + { + result.Add(new Message { Role = ConversationRole.Assistant, Content = content }); + } + + continue; + } + + if (message.Role == AgentRole.Tool) + { + var content = new List(); + while (index < messages.Count && messages[index].Role == AgentRole.Tool) + { + var tool = messages[index]; + content.Add(new ContentBlock + { + ToolResult = new ToolResultBlock + { + ToolUseId = tool.ToolCallId, + Status = tool.IsError ? ToolResultStatus.Error : ToolResultStatus.Success, + Content = ProjectToolResultContent(tool.Content), + }, + }); + index++; + } + + index--; + result.Add(new Message { Role = ConversationRole.User, Content = content }); + } + } + + if (cache && result.LastOrDefault() is { Role: var role, Content: { } lastContent } && role == ConversationRole.User) + { + lastContent.Add(new ContentBlock { CachePoint = CachePoint(retention) }); + } + + return result; + } + + private static List ProjectUserContent(IEnumerable content) + { + var result = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text when NonBlankText(text.Text) is { } sanitized: + result.Add(new ContentBlock { Text = sanitized }); + break; + case JsonContent json: + result.Add(new ContentBlock { Text = json.Json }); + break; + case BinaryContent binary when binary.MediaKind == AgentMediaKind.Image: + result.Add(new ContentBlock { Image = Image(binary) }); + break; + case ResourceContent resource: + result.Add(new ContentBlock { Text = $"[resource media_type={resource.MediaType}] {resource.Uri}" }); + break; + } + } + + if (result.Count == 0) + { + result.Add(new ContentBlock { Text = EmptyText }); + } + + return result; + } + + private static List ProjectAssistantContent(IEnumerable content, string model) + { + var result = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text when NonBlankText(text.Text) is { } sanitized: + result.Add(new ContentBlock { Text = sanitized }); + break; + case ReasoningContent reasoning when NonBlankText(reasoning.Text) is { } thinking: + if (IsClaude(model) && string.IsNullOrWhiteSpace(reasoning.Signature)) + { + result.Add(new ContentBlock { Text = thinking }); + } + else + { + result.Add(new ContentBlock + { + ReasoningContent = new ReasoningContentBlock + { + ReasoningText = new ReasoningTextBlock + { + Text = thinking, + Signature = IsClaude(model) ? reasoning.Signature : null, + }, + }, + }); + } + + break; + case ToolCallContent call: + result.Add(new ContentBlock + { + ToolUse = new ToolUseBlock + { + ToolUseId = call.Id, + Name = call.Name, + Input = Document.FromObject(ParsePlainObject(call.ArgumentsJson)), + }, + }); + break; + } + } + + return result; + } + + private static List ProjectToolResultContent(IEnumerable content) + { + var result = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text when NonBlankText(text.Text) is { } sanitized: + result.Add(new ToolResultContentBlock { Text = sanitized }); + break; + case JsonContent json: + result.Add(new ToolResultContentBlock { Text = json.Json }); + break; + case BinaryContent binary when binary.MediaKind == AgentMediaKind.Image: + result.Add(new ToolResultContentBlock { Image = Image(binary) }); + break; + } + } + + if (result.Count == 0) + { + result.Add(new ToolResultContentBlock { Text = EmptyText }); + } + + return result; + } + + private ToolConfiguration? BuildToolConfiguration(IReadOnlyList tools) + { + if (tools.Count == 0 || _options.ToolChoice == BedrockToolChoice.None) + { + return null; + } + + var values = new List(); + foreach (var tool in tools) + { + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar) + { + throw new NotSupportedException("Bedrock Converse tools do not support grammar-constrained sampling."); + } + + var strict = tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema; + if (strict + && tool.ConstrainedSampling!.Strictness == ToolSchemaStrictness.Require + && !_options.SupportsStrictTools) + { + throw new NotSupportedException("This Bedrock model does not support required strict tool sampling."); + } + + values.Add(new Amazon.BedrockRuntime.Model.Tool + { + ToolSpec = new ToolSpecification + { + Name = tool.Name, + Description = tool.Description, + InputSchema = new ToolInputSchema + { + Json = Document.FromObject(ParsePlainObject(tool.InputSchemaJson)), + }, + Strict = strict && _options.SupportsStrictTools ? true : null, + }, + }); + } + + return new ToolConfiguration + { + Tools = values, + ToolChoice = _options.ToolChoice switch + { + BedrockToolChoice.Auto => new ToolChoice { Auto = new AutoToolChoice() }, + BedrockToolChoice.Any => new ToolChoice { Any = new AnyToolChoice() }, + BedrockToolChoice.Tool => new ToolChoice { Tool = new SpecificToolChoice { Name = _options.RequiredToolName } }, + _ => null, + }, + }; + } + + private static InferenceConfiguration BuildInferenceConfig(ModelParameters parameters) + { + var config = new InferenceConfiguration + { + MaxTokens = parameters.MaxOutputTokens, + Temperature = parameters.Temperature is { } temperature ? (float)temperature : null, + }; + if (parameters.SamplingParametersJson is { } json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.TryGetProperty("topP", out var topP) + || document.RootElement.TryGetProperty("top_p", out topP)) + { + config.TopP = topP.GetSingle(); + } + + if (document.RootElement.TryGetProperty("stopSequences", out var stops) + || document.RootElement.TryGetProperty("stop_sequences", out stops)) + { + config.StopSequences = stops.EnumerateArray().Select(value => value.GetString()!).ToList(); + } + } + + return config; + } + + private static List? BuildSystem( + string systemPrompt, + bool cache, + ModelCacheRetention retention) + { + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + return null; + } + + var result = new List { new() { Text = SanitizeUnicode(systemPrompt) } }; + if (cache) + { + result.Add(new SystemContentBlock { CachePoint = CachePoint(retention) }); + } + + return result; + } + + private Document? BuildAdditionalFields(string model, ModelParameters parameters) + { + var fields = new Dictionary(); + if (!string.IsNullOrWhiteSpace(parameters.ReasoningLevel) + && !string.Equals(parameters.ReasoningLevel, "off", StringComparison.OrdinalIgnoreCase) + && IsClaude(model)) + { + var level = parameters.ReasoningLevel!.ToLowerInvariant(); + var display = IsGovCloud(model, _options.Region) + ? null + : _options.ThinkingDisplay == BedrockThinkingDisplay.Omitted ? "omitted" : "summarized"; + if (SupportsAdaptiveThinking(model)) + { + fields["thinking"] = new Dictionary + { + ["type"] = "adaptive", + ["display"] = display, + }.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value); + fields["output_config"] = new Dictionary + { + ["effort"] = MapEffort(model, level), + }; + } + else + { + var budgetLevel = level is "xhigh" or "max" ? "high" : level; + var budget = parameters.ReasoningBudgets.TryGetValue(budgetLevel, out var custom) + ? custom + : budgetLevel switch + { + "minimal" => 1024, + "low" => 2048, + "medium" => 8192, + _ => 16384, + }; + fields["thinking"] = new Dictionary + { + ["type"] = "enabled", + ["budget_tokens"] = budget, + ["display"] = display, + }.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value); + if (_options.InterleavedThinking) + { + fields["anthropic_beta"] = new[] { "interleaved-thinking-2025-05-14" }; + } + } + } + + foreach (var extension in parameters.Extensions) + { + if (fields.ContainsKey(extension.Key)) + { + throw new InvalidOperationException($"Model extension '{extension.Key}' cannot override a core Bedrock field."); + } + + fields[extension.Key] = ParseJsonOrString(extension.Value); + } + + return fields.Count == 0 ? (Document?)null : Document.FromObject(fields); + } + + private IAmazonBedrockRuntime CreateClient(string model) + { + var config = new AmazonBedrockRuntimeConfig(); + var region = ResolveRegion( + model, + _options.Region, + _options.ServiceUrl, + Environment.GetEnvironmentVariable("AWS_REGION"), + Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION")); + config.RegionEndpoint = RegionEndpoint.GetBySystemName(region); + if (!string.IsNullOrWhiteSpace(_options.ServiceUrl)) + { + config.ServiceURL = _options.ServiceUrl; + config.AuthenticationRegion = region; + } + + var bearerToken = _options.BearerToken + ?? Environment.GetEnvironmentVariable("AWS_BEARER_TOKEN_BEDROCK"); + if (!string.IsNullOrWhiteSpace(bearerToken)) + { + config.AWSTokenProvider = new StaticTokenProvider(bearerToken!); + config.AuthSchemePreference = new List { "httpBearerAuth" }; + } + + AWSCredentials? credentials = null; + if (_options.SkipAuthentication) + { + credentials = new BasicAWSCredentials("dummy-access-key", "dummy-secret-key"); + } + else if (!string.IsNullOrWhiteSpace(_options.Profile)) + { + var chain = new CredentialProfileStoreChain(); + if (!chain.TryGetAWSCredentials(_options.Profile, out credentials)) + { + throw new InvalidOperationException("AWS credential profile '" + _options.Profile + "' was not found."); + } + } + else if (!string.IsNullOrWhiteSpace(_options.AccessKeyId)) + { + credentials = string.IsNullOrWhiteSpace(_options.SessionToken) + ? new BasicAWSCredentials(_options.AccessKeyId, _options.SecretAccessKey) + : new SessionAWSCredentials(_options.AccessKeyId, _options.SecretAccessKey, _options.SessionToken); + } + + return credentials is null + ? new CustomHeadersBedrockClient(config, _headers) + : new CustomHeadersBedrockClient(credentials, config, _headers); + } + + private bool SupportsPromptCaching(string model) + { + if (_options.ForcePromptCaching) + { + return true; + } + + var value = NormalizeModel(model); + if (!value.Contains("claude", StringComparison.Ordinal)) + { + return false; + } + + return value.Contains("-4-", StringComparison.Ordinal) + || value.Contains("claude-3-7-sonnet", StringComparison.Ordinal) + || value.Contains("claude-3-5-haiku", StringComparison.Ordinal) + || value.Contains("fable-5", StringComparison.Ordinal) + || value.Contains("opus-5", StringComparison.Ordinal) + || value.Contains("sonnet-5", StringComparison.Ordinal); + } + + private static CachePointBlock CachePoint(ModelCacheRetention retention) => new() + { + Type = CachePointType.Default, + Ttl = retention == ModelCacheRetention.Long ? CacheTTL.ONE_HOUR : null, + }; + + private static ImageBlock Image(BinaryContent binary) => new() + { + Format = binary.MediaType.ToLowerInvariant() switch + { + "image/jpeg" or "image/jpg" => ImageFormat.Jpeg, + "image/png" => ImageFormat.Png, + "image/gif" => ImageFormat.Gif, + "image/webp" => ImageFormat.Webp, + _ => throw new NotSupportedException("Unsupported Bedrock image type '" + binary.MediaType + "'."), + }, + Source = new ImageSource { Bytes = new MemoryStream(Convert.FromBase64String(binary.Data), writable: false) }, + }; + + private static object ParsePlainObject(string json) + { + using var document = JsonDocument.Parse(json); + return ToPlain(document.RootElement)!; + } + + private static object? ToPlain(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Object => value.EnumerateObject().ToDictionary(pair => pair.Name, pair => ToPlain(pair.Value), StringComparer.Ordinal), + JsonValueKind.Array => value.EnumerateArray().Select(ToPlain).ToArray(), + JsonValueKind.String => value.GetString(), + JsonValueKind.Number when value.TryGetInt64(out var integer) => integer, + JsonValueKind.Number => value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => null, + }; + + private static object ParseJsonOrString(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return ToPlain(document.RootElement)!; + } + catch (JsonException) + { + return value; + } + } + + private static string? NonBlankText(string value) + { + var sanitized = SanitizeUnicode(value); + return string.IsNullOrWhiteSpace(sanitized) ? null : sanitized; + } + + private static string SanitizeUnicode(string value) + { + StringBuilder? builder = null; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (char.IsHighSurrogate(character) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + if (builder is not null) + { + builder.Append(character); + builder.Append(value[index + 1]); + } + + index++; + continue; + } + + if (!char.IsSurrogate(character)) + { + builder?.Append(character); + continue; + } + + builder ??= new StringBuilder(value.Substring(0, index)); + } + + return builder?.ToString() ?? value; + } + + private static string NormalizeToolCallId(string id) + { + var value = new string(id.Select(character => char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_').ToArray()); + return value.Length > 64 ? value.Substring(0, 64) : value; + } + + private static bool IsClaude(string model) => NormalizeModel(model).Contains("claude", StringComparison.Ordinal); + + private static bool SupportsAdaptiveThinking(string model) + { + var value = NormalizeModel(model); + return value.Contains("opus-4-6", StringComparison.Ordinal) + || value.Contains("opus-4-7", StringComparison.Ordinal) + || value.Contains("opus-4-8", StringComparison.Ordinal) + || value.Contains("opus-5", StringComparison.Ordinal) + || value.Contains("sonnet-4-6", StringComparison.Ordinal) + || value.Contains("sonnet-5", StringComparison.Ordinal) + || value.Contains("fable-5", StringComparison.Ordinal); + } + + private static string MapEffort(string model, string level) + { + if (level == "xhigh" && (NormalizeModel(model).Contains("opus-4-7", StringComparison.Ordinal) + || NormalizeModel(model).Contains("opus-4-8", StringComparison.Ordinal) + || NormalizeModel(model).Contains("opus-5", StringComparison.Ordinal) + || NormalizeModel(model).Contains("sonnet-5", StringComparison.Ordinal))) + { + return "xhigh"; + } + + return level switch + { + "minimal" or "low" => "low", + "medium" => "medium", + _ => "high", + }; + } + + private static string NormalizeModel(string model) => + model.ToLowerInvariant().Replace('_', '-').Replace('.', '-').Replace(':', '-').Replace(' ', '-'); + + private static bool IsGovCloud(string model, string? region) => + model.StartsWith("arn:aws-us-gov:", StringComparison.OrdinalIgnoreCase) + || model.StartsWith("us-gov.", StringComparison.OrdinalIgnoreCase) + || region?.StartsWith("us-gov-", StringComparison.OrdinalIgnoreCase) == true; + + private static string? RegionFromArn(string model) + { + if (!model.StartsWith("arn:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parts = model.Split(':'); + return parts.Length > 3 && parts[2] == "bedrock" ? parts[3] : null; + } + + internal static string ResolveRegion( + string model, + string? configuredRegion, + string? serviceUrl, + string? environmentRegion, + string? environmentDefaultRegion) + { + var endpointRegion = RegionFromServiceUrl(serviceUrl); + return RegionFromArn(model) + ?? configuredRegion + ?? environmentRegion + ?? environmentDefaultRegion + ?? endpointRegion + ?? "us-east-1"; + } + + private static string? RegionFromServiceUrl(string? serviceUrl) + { + if (!Uri.TryCreate(serviceUrl, UriKind.Absolute, out var endpoint)) + { + return null; + } + + var pieces = endpoint.Host.Split('.'); + for (var index = 0; index + 1 < pieces.Length; index++) + { + if (string.Equals(pieces[index], "bedrock-runtime", StringComparison.OrdinalIgnoreCase)) + { + return pieces[index + 1]; + } + } + + return null; + } + + private static void ValidateOptions(BedrockConverseProviderOptions options) + { + if (string.IsNullOrWhiteSpace(options.ProviderId) || string.IsNullOrWhiteSpace(options.ApiId)) + { + throw new ArgumentException("Bedrock provider and API identifiers are required.", nameof(options)); + } + + if (!Enum.IsDefined(typeof(BedrockThinkingDisplay), options.ThinkingDisplay) + || options.ToolChoice is { } choice && !Enum.IsDefined(typeof(BedrockToolChoice), choice)) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + if (options.ToolChoice == BedrockToolChoice.Tool && string.IsNullOrWhiteSpace(options.RequiredToolName)) + { + throw new ArgumentException("A Bedrock required tool name is missing.", nameof(options)); + } + + if (options.ToolChoice != BedrockToolChoice.Tool && options.RequiredToolName is not null) + { + throw new ArgumentException("Only specific tool choice can carry a required name.", nameof(options)); + } + + if (!string.IsNullOrWhiteSpace(options.ServiceUrl)) + { + if (!Uri.TryCreate(options.ServiceUrl, UriKind.Absolute, out var endpoint) + || endpoint.UserInfo.Length > 0 + || endpoint.Fragment.Length > 0 + || endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException( + "A Bedrock service URL must be an absolute HTTP or HTTPS URL without embedded credentials or a fragment.", + nameof(options)); + } + + if (endpoint.Scheme == Uri.UriSchemeHttp && !endpoint.IsLoopback && !options.AllowInsecureHttp) + { + throw new ArgumentException( + "A remote Bedrock service URL must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(options)); + } + } + + var hasAccess = !string.IsNullOrWhiteSpace(options.AccessKeyId); + var hasSecret = !string.IsNullOrWhiteSpace(options.SecretAccessKey); + if (hasAccess != hasSecret) + { + throw new ArgumentException("AWS access key ID and secret access key must be supplied together.", nameof(options)); + } + + if (options.MaxResponseCharacters <= 0 + || options.MaxToolCallsPerResponse <= 0 + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "Bedrock protocol limits must be positive."); + } + + if (options.RequestMetadata.Count > 50 + || options.RequestMetadata.Any(pair => string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.StartsWith("aws:", StringComparison.OrdinalIgnoreCase) + || pair.Key.Length > 64 + || pair.Value is null + || pair.Value.Length > 256)) + { + throw new ArgumentException("Bedrock request metadata is invalid.", nameof(options)); + } + + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); + } + + private static bool IsReservedHeader(string key) => + key.Equals("authorization", StringComparison.OrdinalIgnoreCase) + || key.Equals("host", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("x-amz-", StringComparison.OrdinalIgnoreCase); + + internal static IReadOnlyDictionary NormalizeHeaders( + IEnumerable> headers) => + new ReadOnlyDictionary( + headers + .Where(pair => pair.Value is not null && !IsReservedHeader(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value!, StringComparer.OrdinalIgnoreCase)); + + private sealed class StaticTokenProvider : IAWSTokenProvider + { + private readonly AWSToken _token; + + public StaticTokenProvider(string token) + { + _token = new AWSToken { Token = token }; + } + + public Task> TryResolveTokenAsync(CancellationToken cancellationToken) => + Task.FromResult(new TryResponse { Success = true, Value = _token }); + } +} diff --git a/src/OpenGameAgent.Providers.Bedrock/BedrockProtocol.cs b/src/OpenGameAgent.Providers.Bedrock/BedrockProtocol.cs new file mode 100644 index 0000000..3b68988 --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/BedrockProtocol.cs @@ -0,0 +1,111 @@ +using Amazon.BedrockRuntime.Model; + +namespace OpenGameAgent.Providers.Bedrock; + +public delegate IAsyncEnumerable BedrockConverseTransport( + ConverseStreamRequest request, + CancellationToken cancellationToken); + +public enum BedrockProtocolEventKind +{ + MessageStarted, + ContentStarted, + ContentDelta, + ContentStopped, + MessageStopped, + Metadata, +} + +public sealed class BedrockProtocolEvent +{ + private BedrockProtocolEvent(BedrockProtocolEventKind kind) + { + Kind = kind; + } + + public BedrockProtocolEventKind Kind { get; } + + public int ContentIndex { get; private set; } = -1; + + public string? Role { get; private set; } + + public string? Text { get; private set; } + + public string? ReasoningText { get; private set; } + + public string? ReasoningSignature { get; private set; } + + public string? ToolCallId { get; private set; } + + public string? ToolName { get; private set; } + + public string? ToolArgumentsDelta { get; private set; } + + public string? StopReason { get; private set; } + + public long InputTokens { get; private set; } + + public long OutputTokens { get; private set; } + + public long CacheReadTokens { get; private set; } + + public long CacheWriteTokens { get; private set; } + + public static BedrockProtocolEvent MessageStart(string role) => new(BedrockProtocolEventKind.MessageStarted) + { + Role = role ?? throw new ArgumentNullException(nameof(role)), + }; + + public static BedrockProtocolEvent ContentStart(int index, string? toolCallId = null, string? toolName = null) => + new(BedrockProtocolEventKind.ContentStarted) + { + ContentIndex = RequireIndex(index), + ToolCallId = toolCallId, + ToolName = toolName, + }; + + public static BedrockProtocolEvent TextDelta(int index, string text) => new(BedrockProtocolEventKind.ContentDelta) + { + ContentIndex = RequireIndex(index), + Text = text ?? throw new ArgumentNullException(nameof(text)), + }; + + public static BedrockProtocolEvent ReasoningDelta(int index, string? text = null, string? signature = null) => + new(BedrockProtocolEventKind.ContentDelta) + { + ContentIndex = RequireIndex(index), + ReasoningText = text, + ReasoningSignature = signature, + }; + + public static BedrockProtocolEvent ToolDelta(int index, string arguments) => new(BedrockProtocolEventKind.ContentDelta) + { + ContentIndex = RequireIndex(index), + ToolArgumentsDelta = arguments ?? throw new ArgumentNullException(nameof(arguments)), + }; + + public static BedrockProtocolEvent ContentStop(int index) => new(BedrockProtocolEventKind.ContentStopped) + { + ContentIndex = RequireIndex(index), + }; + + public static BedrockProtocolEvent MessageStop(string stopReason) => new(BedrockProtocolEventKind.MessageStopped) + { + StopReason = string.IsNullOrWhiteSpace(stopReason) + ? throw new ArgumentException("A Bedrock stop reason is required.", nameof(stopReason)) + : stopReason, + }; + + public static BedrockProtocolEvent Usage(long input, long output, long cacheRead = 0, long cacheWrite = 0) => + new(BedrockProtocolEventKind.Metadata) + { + InputTokens = RequireCount(input, nameof(input)), + OutputTokens = RequireCount(output, nameof(output)), + CacheReadTokens = RequireCount(cacheRead, nameof(cacheRead)), + CacheWriteTokens = RequireCount(cacheWrite, nameof(cacheWrite)), + }; + + private static int RequireIndex(int index) => index >= 0 ? index : throw new ArgumentOutOfRangeException(nameof(index)); + + private static long RequireCount(long count, string name) => count >= 0 ? count : throw new ArgumentOutOfRangeException(name); +} diff --git a/src/OpenGameAgent.Providers.Bedrock/BedrockStreamState.cs b/src/OpenGameAgent.Providers.Bedrock/BedrockStreamState.cs new file mode 100644 index 0000000..99290a5 --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/BedrockStreamState.cs @@ -0,0 +1,364 @@ +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Bedrock; + +internal sealed class BedrockStreamState +{ + private readonly string _model; + private readonly string _provider; + private readonly string _api; + private readonly int _maximumCharacters; + private readonly int _maximumToolCalls; + private readonly List _blocks = new(); + private readonly Dictionary _byProtocolIndex = new(); + private long _characters; + private ModelUsage _usage = new(); + private ModelStopReason _stopReason = ModelStopReason.Pending; + private string? _rawStopReason; + private string? _errorMessage; + private bool _messageStarted; + private bool _messageStopped; + + public BedrockStreamState(string model, string provider, string api, int maximumCharacters, int maximumToolCalls) + { + _model = model; + _provider = provider; + _api = api; + _maximumCharacters = maximumCharacters; + _maximumToolCalls = maximumToolCalls; + } + + public ModelResponse Partial() => Build(ModelStopReason.Pending, null, final: false); + + public IReadOnlyList Apply(BedrockProtocolEvent item) + { + var updates = new List(); + switch (item.Kind) + { + case BedrockProtocolEventKind.MessageStarted: + if (_messageStarted || !string.Equals(item.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Bedrock started an invalid assistant message."); + } + + _messageStarted = true; + break; + case BedrockProtocolEventKind.ContentStarted: + RequireActiveMessage(); + if (_byProtocolIndex.ContainsKey(item.ContentIndex)) + { + throw new InvalidDataException("Bedrock started a duplicate content block."); + } + + if (!string.IsNullOrWhiteSpace(item.ToolCallId) || !string.IsNullOrWhiteSpace(item.ToolName)) + { + if (string.IsNullOrWhiteSpace(item.ToolCallId) || string.IsNullOrWhiteSpace(item.ToolName)) + { + throw new InvalidDataException("A Bedrock tool block requires both ID and name."); + } + + if (_blocks.Count(value => value.Kind == BlockKind.Tool) >= _maximumToolCalls) + { + throw new InvalidDataException("The Bedrock response exceeded the configured tool-call limit."); + } + + var block = new Block(BlockKind.Tool, item.ContentIndex) + { + Id = item.ToolCallId, + Name = item.ToolName, + }; + _blocks.Add(block); + _byProtocolIndex.Add(item.ContentIndex, block); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallStarted, + Partial(), + contentIndex: _blocks.Count - 1, + toolCallId: block.Id, + toolName: block.Name)); + } + + break; + case BedrockProtocolEventKind.ContentDelta: + RequireActiveMessage(); + ApplyDelta(item, updates); + break; + case BedrockProtocolEventKind.ContentStopped: + RequireActiveMessage(); + StopBlock(item.ContentIndex, updates); + break; + case BedrockProtocolEventKind.MessageStopped: + RequireActiveMessage(); + if (_messageStopped) + { + throw new InvalidDataException("Bedrock stopped the message more than once."); + } + + _rawStopReason = item.StopReason; + (_stopReason, _errorMessage) = MapStopReason(item.StopReason!); + _messageStopped = true; + break; + case BedrockProtocolEventKind.Metadata: + _usage = new ModelUsage( + item.InputTokens, + item.OutputTokens, + item.CacheReadTokens, + item.CacheWriteTokens); + break; + default: + throw new InvalidDataException("Bedrock returned an unsupported protocol event."); + } + + return updates; + } + + public ModelResponse Complete() + { + if (!_messageStarted || !_messageStopped) + { + throw new InvalidDataException("The Bedrock stream ended before message_stop."); + } + + if (_blocks.Any(value => !value.Ended)) + { + throw new InvalidDataException("The Bedrock stream ended with an incomplete content block."); + } + + return Build(_stopReason, _errorMessage, final: true); + } + + private void ApplyDelta(BedrockProtocolEvent item, ICollection updates) + { + if (item.Text is not null) + { + var block = GetOrCreate(item.ContentIndex, BlockKind.Text, updates); + Append(block.Buffer, item.Text); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.TextDelta, + Partial(), + item.Text, + _blocks.IndexOf(block))); + return; + } + + if (item.ToolArgumentsDelta is not null) + { + if (!_byProtocolIndex.TryGetValue(item.ContentIndex, out var block) || block.Kind != BlockKind.Tool) + { + throw new InvalidDataException("Bedrock streamed tool arguments for a missing tool block."); + } + + Append(block.Buffer, item.ToolArgumentsDelta); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + item.ToolArgumentsDelta, + _blocks.IndexOf(block), + block.Id, + block.Name)); + return; + } + + if (item.ReasoningText is not null || item.ReasoningSignature is not null) + { + var block = GetOrCreate(item.ContentIndex, BlockKind.Reasoning, updates); + if (item.ReasoningText is { } reasoning) + { + Append(block.Buffer, reasoning); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ReasoningDelta, + Partial(), + reasoning, + _blocks.IndexOf(block))); + } + + if (item.ReasoningSignature is { } signature) + { + Append(block.Signature, signature); + } + } + } + + private Block GetOrCreate(int protocolIndex, BlockKind kind, ICollection updates) + { + if (_byProtocolIndex.TryGetValue(protocolIndex, out var existing)) + { + if (existing.Kind != kind) + { + throw new InvalidDataException("Bedrock changed a content block's type while streaming."); + } + + return existing; + } + + var block = new Block(kind, protocolIndex); + _blocks.Add(block); + _byProtocolIndex.Add(protocolIndex, block); + updates.Add(ModelStreamEvent.Update( + kind == BlockKind.Text ? ModelStreamEventKind.TextStarted : ModelStreamEventKind.ReasoningStarted, + Partial(), + contentIndex: _blocks.Count - 1)); + return block; + } + + private void StopBlock(int protocolIndex, ICollection updates) + { + if (!_byProtocolIndex.TryGetValue(protocolIndex, out var block) || block.Ended) + { + throw new InvalidDataException("Bedrock stopped a missing or already stopped content block."); + } + + if (block.Kind == BlockKind.Tool && !IsJsonObject(block.Buffer.ToString())) + { + throw new InvalidDataException("A completed Bedrock tool call did not contain a JSON object."); + } + + block.Ended = true; + var kind = block.Kind switch + { + BlockKind.Text => ModelStreamEventKind.TextEnded, + BlockKind.Reasoning => ModelStreamEventKind.ReasoningEnded, + _ => ModelStreamEventKind.ToolCallEnded, + }; + var contentIndex = _blocks.IndexOf(block); + var partial = Partial(); + var toolCall = kind == ModelStreamEventKind.ToolCallEnded + ? partial.Content[contentIndex] as ToolCallContent + ?? throw new InvalidDataException("A completed Bedrock tool block did not produce a tool call.") + : null; + updates.Add(ModelStreamEvent.Update( + kind, + partial, + contentIndex: contentIndex, + toolCallId: block.Id, + toolName: block.Name, + toolCall: toolCall, + content: kind is ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded + ? block.Buffer.ToString() + : null)); + } + + private ModelResponse Build(ModelStopReason reason, string? errorMessage, bool final) + { + var content = new List(); + foreach (var block in _blocks) + { + if (block.Kind == BlockKind.Text) + { + content.Add(new TextContent(block.Buffer.ToString())); + } + else if (block.Kind == BlockKind.Reasoning) + { + content.Add(new ReasoningContent(block.Buffer.ToString(), block.Signature.ToString())); + } + else + { + var streamedArguments = block.Buffer.ToString(); + var arguments = final + ? string.IsNullOrWhiteSpace(streamedArguments) + ? "{}" + : IsJsonObject(streamedArguments) + ? streamedArguments + : reason == ModelStopReason.Length + ? StreamingJson.ParseObject(streamedArguments) + : "{}" + : StreamingJson.ParseObject(streamedArguments); + if (final + && reason != ModelStopReason.Length + && arguments == "{}" + && !string.IsNullOrWhiteSpace(streamedArguments)) + { + throw new InvalidDataException("A completed Bedrock tool call did not contain a JSON object."); + } + + content.Add(new ToolCallContent(block.Id!, block.Name!, arguments)); + } + } + + return new ModelResponse( + content, + reason, + _usage, + errorMessage, + _provider, + _api, + _model, + rawStopReason: _rawStopReason); + } + + private void RequireActiveMessage() + { + if (!_messageStarted || _messageStopped) + { + throw new InvalidDataException("A Bedrock content event arrived outside an active message."); + } + } + + private void Append(StringBuilder builder, string value) + { + _characters = checked(_characters + value.Length); + if (_characters > _maximumCharacters) + { + throw new InvalidDataException("The Bedrock response exceeded the configured character limit."); + } + + builder.Append(value); + } + + private static bool IsJsonObject(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static (ModelStopReason Reason, string? Error) MapStopReason(string reason) => reason switch + { + "end_turn" or "stop_sequence" => (ModelStopReason.Stop, null), + "max_tokens" or "model_context_window_exceeded" => (ModelStopReason.Length, null), + "tool_use" => (ModelStopReason.ToolUse, null), + _ => (ModelStopReason.Error, "Provider stopped with: " + reason), + }; + + private enum BlockKind + { + Text, + Reasoning, + Tool, + } + + private sealed class Block + { + public Block(BlockKind kind, int protocolIndex) + { + Kind = kind; + ProtocolIndex = protocolIndex; + } + + public BlockKind Kind { get; } + + public int ProtocolIndex { get; } + + public StringBuilder Buffer { get; } = new(); + + public StringBuilder Signature { get; } = new(); + + public string? Id { get; set; } + + public string? Name { get; set; } + + public bool Ended { get; set; } + } +} diff --git a/src/OpenGameAgent.Providers.Bedrock/CustomHeadersBedrockClient.cs b/src/OpenGameAgent.Providers.Bedrock/CustomHeadersBedrockClient.cs new file mode 100644 index 0000000..1e93b4d --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/CustomHeadersBedrockClient.cs @@ -0,0 +1,55 @@ +using Amazon.BedrockRuntime; +using Amazon.Runtime; +using Amazon.Runtime.Internal; + +namespace OpenGameAgent.Providers.Bedrock; + +internal sealed class CustomHeadersBedrockClient : AmazonBedrockRuntimeClient +{ + public CustomHeadersBedrockClient( + AmazonBedrockRuntimeConfig config, + IReadOnlyDictionary headers) + : base(config) + { + AddCustomHeadersHandler(headers); + } + + public CustomHeadersBedrockClient( + AWSCredentials credentials, + AmazonBedrockRuntimeConfig config, + IReadOnlyDictionary headers) + : base(credentials, config) + { + AddCustomHeadersHandler(headers); + } + + private void AddCustomHeadersHandler(IReadOnlyDictionary headers) + { + if (headers.Count > 0) + { + RuntimePipeline.AddHandlerBefore(new CustomHeadersHandler(headers)); + } + } + + private sealed class CustomHeadersHandler : PipelineHandler + { + private readonly IReadOnlyDictionary _headers; + + public CustomHeadersHandler(IReadOnlyDictionary headers) + { + _headers = headers; + } + + public override void InvokeSync(IExecutionContext executionContext) + { + AwsBedrockTransport.ApplyHeaders(executionContext.RequestContext.Request.Headers, _headers); + base.InvokeSync(executionContext); + } + + public override Task InvokeAsync(IExecutionContext executionContext) + { + AwsBedrockTransport.ApplyHeaders(executionContext.RequestContext.Request.Headers, _headers); + return base.InvokeAsync(executionContext); + } + } +} diff --git a/src/OpenGameAgent.Providers.Bedrock/OpenGameAgent.Providers.Bedrock.csproj b/src/OpenGameAgent.Providers.Bedrock/OpenGameAgent.Providers.Bedrock.csproj new file mode 100644 index 0000000..692b0bb --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/OpenGameAgent.Providers.Bedrock.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + OpenGameAgent.Providers.Bedrock + Native Amazon Bedrock ConverseStream transport for OpenGameAgent. + + + + + + + + + + diff --git a/src/OpenGameAgent.Providers.Bedrock/packages.lock.json b/src/OpenGameAgent.Providers.Bedrock/packages.lock.json new file mode 100644 index 0000000..2ee7d58 --- /dev/null +++ b/src/OpenGameAgent.Providers.Bedrock/packages.lock.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "AWSSDK.BedrockRuntime": { + "type": "Direct", + "requested": "[4.0.101, )", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Text.Json": "8.0.6" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.Google/GoogleGenerativeProvider.cs b/src/OpenGameAgent.Providers.Google/GoogleGenerativeProvider.cs new file mode 100644 index 0000000..77f3815 --- /dev/null +++ b/src/OpenGameAgent.Providers.Google/GoogleGenerativeProvider.cs @@ -0,0 +1,1034 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.Google; + +public delegate ValueTask GoogleCredentialProvider(CancellationToken cancellationToken); + +public enum GoogleApiFlavor +{ + Gemini, + Vertex, +} + +public enum GoogleCredentialPlacement +{ + ApiKeyHeader, + BearerToken, + None, +} + +public enum GoogleToolChoice +{ + Auto, + None, + Any, +} + +public sealed class GoogleGenerativeProviderOptions +{ + public GoogleGenerativeProviderOptions(HttpClient httpClient, Uri endpoint, GoogleApiFlavor flavor = GoogleApiFlavor.Gemini) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Flavor = flavor; + ProviderId = flavor == GoogleApiFlavor.Vertex ? "google-vertex" : "google"; + ApiId = flavor == GoogleApiFlavor.Vertex ? "google-vertex" : "google-generative-ai"; + CredentialPlacement = flavor == GoogleApiFlavor.Vertex + ? GoogleCredentialPlacement.BearerToken + : GoogleCredentialPlacement.ApiKeyHeader; + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public GoogleApiFlavor Flavor { get; } + + public string ProviderId { get; set; } + + public string ApiId { get; set; } + + public string? Credential { get; set; } + + public GoogleCredentialProvider? GetCredentialAsync { get; set; } + + public GoogleCredentialPlacement CredentialPlacement { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public GoogleToolChoice? ToolChoice { get; set; } + + public bool SupportsImages { get; set; } = true; + + public bool UseLegacyOpenApiToolSchemas { get; set; } + + public bool AllowInsecureHttp { get; set; } + + public int MaxEventCharacters { get; set; } = 4_000_000; + + public int MaxErrorCharacters { get; set; } = 64_000; + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseCharacters { get; set; } = 16_000_000; + + public int MaxToolCallsPerResponse { get; set; } = 256; +} + +public sealed class GoogleGenerativeProvider : IModelProvider, IModelProviderCapabilities +{ + private readonly GoogleGenerativeProviderOptions _options; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; + private readonly IReadOnlyCollection _supportedApis; + + public GoogleGenerativeProvider(GoogleGenerativeProviderOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + ValidateOptions(options); + _headers = new ReadOnlyDictionary( + new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase)); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + _supportedApis = Array.AsReadOnly(new[] { options.ApiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => false; + + public bool SupportsDeferredResponses => false; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("This provider currently uses the Google server-sent-event transport."); + } + + var endpoint = ResolveEndpoint(_options.Endpoint, request.Model); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint); + var credential = _options.GetCredentialAsync is null + ? _options.Credential + : await ProviderCallbackRunner.RunAsync( + token => _options.GetCredentialAsync(token), + cancellationToken) + .ConfigureAwait(false); + ApplyHeaders(httpRequest, credential, request); + httpRequest.Content = new ByteArrayContent(SerializeRequest(request)); + httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + + using var response = await _options.HttpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromHttpResponse( + _options.ProviderId, + _options.ApiId, + request.Model, + response), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var error = await ReadBoundedAsync(response.Content, _options.MaxErrorCharacters, cancellationToken) + .ConfigureAwait(false); + var retry = ProviderHttpRetryMetadata.FromResponse(response, errorText: error); + throw new ModelProviderException( + $"The Google endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", + retry.IsTransient, + retry.RetryAfter, + (int)response.StatusCode); + } + + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var state = new GoogleStreamState( + request.Model, + _options.ProviderId, + _options.ApiId, + _options.MaxResponseCharacters, + _options.MaxToolCallsPerResponse); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + + await foreach (var data in ReadSseDataAsync(reader, _options.MaxEventCharacters, cancellationToken)) + { + if (data.Length == 0 || string.Equals(data, "[DONE]", StringComparison.Ordinal)) + { + continue; + } + + foreach (var update in state.Apply(data)) + { + yield return update; + } + } + + foreach (var update in state.CloseOpenBlock()) + { + yield return update; + } + + yield return ModelStreamEvent.Terminal(state.Complete()); + } + + private byte[] SerializeRequest(ModelRequest request) + { + var requiresIds = RequiresToolCallId(request.Model); + var messages = ProviderTranscript.Normalize( + request.Messages, + _options.ProviderId, + _options.ApiId, + request.Model, + requiresIds ? (id, _, _, _) => NormalizeToolCallId(id) : null); + var payload = new Dictionary + { + ["contents"] = ProjectMessages(messages, request.Model, requiresIds), + }; + + if (!string.IsNullOrWhiteSpace(request.SystemPrompt)) + { + payload["systemInstruction"] = new Dictionary + { + ["parts"] = new object[] + { + new Dictionary { ["text"] = SanitizeUnicode(request.SystemPrompt) }, + }, + }; + } + + var generationConfig = new Dictionary(); + if (request.Parameters.Temperature is { } temperature) + { + generationConfig["temperature"] = temperature; + } + + if (request.Parameters.MaxOutputTokens is { } maxOutputTokens) + { + generationConfig["maxOutputTokens"] = maxOutputTokens; + } + + ApplyThinking(generationConfig, request.Model, request.Parameters); + MergeSampling(generationConfig, request.Parameters.SamplingParametersJson); + if (generationConfig.Count > 0) + { + payload["generationConfig"] = generationConfig; + } + + if (request.Tools.Count > 0) + { + payload["tools"] = new object[] + { + new Dictionary + { + ["functionDeclarations"] = ProjectTools(request.Tools, request.Model), + }, + }; + var mode = ResolveToolMode(request.Tools, request.Model); + if (mode is not null) + { + payload["toolConfig"] = new Dictionary + { + ["functionCallingConfig"] = new Dictionary { ["mode"] = mode }, + }; + } + } + + foreach (var extension in request.Parameters.Extensions) + { + if (payload.ContainsKey(extension.Key)) + { + throw new InvalidOperationException($"Model extension '{extension.Key}' cannot override a core request field."); + } + + payload[extension.Key] = ParseJsonOrString(extension.Value); + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload); + if (bytes.Length > _options.MaxRequestBytes) + { + throw new InvalidOperationException("The Google request exceeded the configured byte limit."); + } + + return bytes; + } + + private IReadOnlyList ProjectMessages( + IReadOnlyList messages, + string model, + bool requiresIds) + { + var projected = new List(); + foreach (var message in messages) + { + if (message.Role is AgentRole.User or AgentRole.Custom) + { + var parts = ProjectUserParts(message.Content); + if (parts.Count > 0) + { + projected.Add(new ProjectedContent("user", parts)); + } + + continue; + } + + if (message.Role == AgentRole.Assistant) + { + var parts = ProjectAssistantParts(message.Content, requiresIds); + if (parts.Count > 0) + { + projected.Add(new ProjectedContent("model", parts)); + } + + continue; + } + + if (message.Role == AgentRole.Tool) + { + ProjectToolResult(projected, message, model, requiresIds); + } + } + + return projected.Select(value => (object)new Dictionary + { + ["role"] = value.Role, + ["parts"] = value.Parts, + }).ToArray(); + } + + private IReadOnlyList ProjectUserParts(IEnumerable content) + { + var parts = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text: + parts.Add(new Dictionary { ["text"] = SanitizeUnicode(text.Text) }); + break; + case JsonContent json: + parts.Add(new Dictionary { ["text"] = json.Json }); + break; + case BinaryContent binary when binary.MediaKind == AgentMediaKind.Image && _options.SupportsImages: + parts.Add(InlineImage(binary)); + break; + case BinaryContent binary: + parts.Add(new Dictionary + { + ["text"] = $"(binary omitted: {binary.MediaType})", + }); + break; + case ResourceContent resource: + parts.Add(new Dictionary + { + ["text"] = $"[resource media_type={resource.MediaType}] {resource.Uri}", + }); + break; + } + } + + return parts; + } + + private static IReadOnlyList ProjectAssistantParts(IEnumerable content, bool requiresIds) + { + var parts = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text when text.Text.Length > 0 || IsValidSignature(text.Signature): + var textPart = new Dictionary { ["text"] = SanitizeUnicode(text.Text) }; + AddSignature(textPart, text.Signature); + parts.Add(textPart); + break; + case ReasoningContent reasoning when reasoning.Text.Length > 0 || IsValidSignature(reasoning.Signature): + var reasoningPart = new Dictionary + { + ["thought"] = true, + ["text"] = SanitizeUnicode(reasoning.Text), + }; + AddSignature(reasoningPart, reasoning.Signature); + parts.Add(reasoningPart); + break; + case ToolCallContent call: + using (var arguments = JsonDocument.Parse(call.ArgumentsJson)) + { + var functionCall = new Dictionary + { + ["name"] = call.Name, + ["args"] = arguments.RootElement.Clone(), + }; + if (requiresIds) + { + functionCall["id"] = call.Id; + } + + var toolPart = new Dictionary { ["functionCall"] = functionCall }; + AddSignature(toolPart, call.ThoughtSignature); + parts.Add(toolPart); + } + + break; + } + } + + return parts; + } + + private void ProjectToolResult( + IList projected, + AgentMessage message, + string model, + bool requiresIds) + { + var text = string.Join("\n", message.Content.Select(item => item switch + { + TextContent value => value.Text, + JsonContent value => value.Json, + _ => null, + }).Where(value => value is not null)); + var images = _options.SupportsImages + ? message.Content.OfType() + .Where(value => value.MediaKind == AgentMediaKind.Image) + .ToArray() + : Array.Empty(); + var supportsNestedImages = SupportsMultimodalFunctionResponse(model); + var responseValue = text.Length > 0 ? SanitizeUnicode(text) : images.Length > 0 ? "(see attached image)" : string.Empty; + var response = new Dictionary + { + [message.IsError ? "error" : "output"] = responseValue, + }; + var functionResponse = new Dictionary + { + ["name"] = message.ToolName, + ["response"] = response, + }; + if (requiresIds) + { + functionResponse["id"] = message.ToolCallId; + } + + if (images.Length > 0 && supportsNestedImages) + { + functionResponse["parts"] = images.Select(value => (object)InlineImage(value)).ToArray(); + } + + var part = new Dictionary { ["functionResponse"] = functionResponse }; + if (projected.LastOrDefault() is { Role: "user" } last + && last.Parts.All(IsFunctionResponsePart)) + { + last.Parts.Add(part); + } + else + { + projected.Add(new ProjectedContent("user", new[] { part })); + } + + if (images.Length > 0 && !supportsNestedImages) + { + var imageParts = new List + { + new Dictionary { ["text"] = "Tool result image:" }, + }; + imageParts.AddRange(images.Select(value => (object)InlineImage(value))); + projected.Add(new ProjectedContent("user", imageParts)); + } + } + + private IReadOnlyList ProjectTools(IReadOnlyList tools, string model) + { + var result = new List(tools.Count); + foreach (var tool in tools) + { + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar) + { + throw new NotSupportedException("Google function declarations do not support grammar-constrained tools."); + } + + using var schema = JsonDocument.Parse(tool.InputSchemaJson); + var declaration = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + }; + if (_options.UseLegacyOpenApiToolSchemas) + { + declaration["parameters"] = SanitizeOpenApiSchema(schema.RootElement); + } + else + { + declaration["parametersJsonSchema"] = schema.RootElement.Clone(); + } + + result.Add(declaration); + } + + return result; + } + + private string? ResolveToolMode(IReadOnlyList tools, string model) + { + var supportsStrict = SupportsStrictToolSampling(model); + var strictRequested = tools.Any(tool => tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema); + var strictRequired = tools.Any(tool => tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema + && tool.ConstrainedSampling.Strictness == ToolSchemaStrictness.Require); + if (strictRequired && !supportsStrict) + { + throw new NotSupportedException("The selected Google model does not support required JSON-schema tool sampling."); + } + + if (_options.ToolChoice == GoogleToolChoice.None) + { + return "NONE"; + } + + if (_options.ToolChoice == GoogleToolChoice.Any) + { + return "ANY"; + } + + if (strictRequested && supportsStrict) + { + return "VALIDATED"; + } + + return _options.ToolChoice == GoogleToolChoice.Auto ? "AUTO" : null; + } + + private static void ApplyThinking(IDictionary config, string model, ModelParameters parameters) + { + if (string.IsNullOrWhiteSpace(parameters.ReasoningLevel)) + { + return; + } + + var level = parameters.ReasoningLevel!.Trim().ToLowerInvariant(); + var thinking = new Dictionary(); + if (level == "off") + { + if (IsGemini3Pro(model)) + { + thinking["thinkingLevel"] = "LOW"; + } + else if (IsGemini3Flash(model) || IsGemma4(model)) + { + thinking["thinkingLevel"] = "MINIMAL"; + } + else + { + thinking["thinkingBudget"] = 0; + } + } + else + { + thinking["includeThoughts"] = true; + if (IsGemini3Pro(model) || IsGemini3Flash(model) || IsGemma4(model)) + { + thinking["thinkingLevel"] = ResolveThinkingLevel(model, level); + } + else + { + thinking["thinkingBudget"] = ResolveThinkingBudget(model, level, parameters.ReasoningBudgets); + } + } + + config["thinkingConfig"] = thinking; + } + + private static string ResolveThinkingLevel(string model, string level) + { + var normalized = level switch + { + "minimal" => "MINIMAL", + "low" => "LOW", + "medium" => "MEDIUM", + "high" or "xhigh" or "max" => "HIGH", + _ => throw new ArgumentException("Unsupported Google reasoning level '" + level + "'."), + }; + if (IsGemini3Pro(model)) + { + return normalized is "MINIMAL" or "LOW" ? "LOW" : "HIGH"; + } + + if (IsGemma4(model)) + { + return normalized is "MINIMAL" or "LOW" ? "MINIMAL" : "HIGH"; + } + + return normalized; + } + + private static int ResolveThinkingBudget( + string model, + string level, + IReadOnlyDictionary customBudgets) + { + if (customBudgets.TryGetValue(level, out var custom)) + { + return custom; + } + + var normalized = level is "xhigh" or "max" ? "high" : level; + return (model.ToLowerInvariant(), normalized) switch + { + (var id, "minimal") when id.Contains("2.5-flash-lite", StringComparison.Ordinal) => 512, + (var id, "minimal") when id.Contains("2.5", StringComparison.Ordinal) => 128, + (var id, "low") when id.Contains("2.5", StringComparison.Ordinal) => 2048, + (var id, "medium") when id.Contains("2.5", StringComparison.Ordinal) => 8192, + (var id, "high") when id.Contains("2.5-pro", StringComparison.Ordinal) => 32768, + (var id, "high") when id.Contains("2.5-flash", StringComparison.Ordinal) => 24576, + (_, "minimal" or "low" or "medium" or "high") => -1, + _ => throw new ArgumentException("Unsupported Google reasoning level '" + level + "'."), + }; + } + + private static void MergeSampling(IDictionary config, string? json) + { + if (json is null) + { + return; + } + + using var document = JsonDocument.Parse(json); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (config.ContainsKey(property.Name)) + { + throw new InvalidOperationException($"Sampling parameter '{property.Name}' cannot override a core request field."); + } + + config[property.Name] = property.Value.Clone(); + } + } + + private void ApplyHeaders(HttpRequestMessage httpRequest, string? credential, ModelRequest request) + { + if (_options.CredentialPlacement != GoogleCredentialPlacement.None && string.IsNullOrWhiteSpace(credential)) + { + throw new InvalidOperationException("A Google credential is required."); + } + + var suppressed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var header in _headers) + { + httpRequest.Headers.Remove(header.Key); + if (header.Value is null) + { + suppressed.Add(header.Key); + } + else if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + throw new InvalidOperationException($"Google request header '{header.Key}' is invalid."); + } + } + + if (_options.CredentialPlacement == GoogleCredentialPlacement.ApiKeyHeader) + { + httpRequest.Headers.Remove("x-goog-api-key"); + httpRequest.Headers.TryAddWithoutValidation("x-goog-api-key", credential); + } + else if (_options.CredentialPlacement == GoogleCredentialPlacement.BearerToken) + { + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential); + } + + if (!string.IsNullOrWhiteSpace(request.SessionId) + && !suppressed.Contains("x-goog-request-params") + && !httpRequest.Headers.Contains("x-goog-request-params")) + { + httpRequest.Headers.TryAddWithoutValidation("x-goog-request-params", "session_id=" + request.SessionId); + } + } + + private static Uri ResolveEndpoint(Uri template, string model) + { + var value = template.OriginalString.Replace("{model}", Uri.EscapeDataString(model), StringComparison.Ordinal); + if (!value.Contains("alt=", StringComparison.OrdinalIgnoreCase)) + { + value += value.Contains('?', StringComparison.Ordinal) ? "&alt=sse" : "?alt=sse"; + } + + return new Uri(value, UriKind.Absolute); + } + + private static Dictionary InlineImage(BinaryContent binary) => new() + { + ["inlineData"] = new Dictionary + { + ["mimeType"] = binary.MediaType, + ["data"] = binary.Data, + }, + }; + + private static bool IsFunctionResponsePart(object value) => + value is Dictionary dictionary && dictionary.ContainsKey("functionResponse"); + + private static void AddSignature(IDictionary part, string? signature) + { + if (IsValidSignature(signature)) + { + part["thoughtSignature"] = signature; + } + } + + private static bool IsValidSignature(string? signature) + { + if (string.IsNullOrEmpty(signature) || signature!.Length % 4 != 0) + { + return false; + } + + try + { + Convert.FromBase64String(signature); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static bool RequiresToolCallId(string model) + { + var lower = model.ToLowerInvariant(); + return lower.StartsWith("claude-", StringComparison.Ordinal) + || lower.StartsWith("gpt-oss-", StringComparison.Ordinal) + || GeminiMajorVersion(lower) is >= 3; + } + + private static bool SupportsMultimodalFunctionResponse(string model) + { + var major = GeminiMajorVersion(model.ToLowerInvariant()); + return major is null or >= 3; + } + + private static bool SupportsStrictToolSampling(string model) => GeminiMajorVersion(model.ToLowerInvariant()) is >= 3; + + private static int? GeminiMajorVersion(string model) + { + var prefix = model.StartsWith("gemini-live-", StringComparison.Ordinal) + ? "gemini-live-" + : model.StartsWith("gemini-", StringComparison.Ordinal) ? "gemini-" : null; + if (prefix is null) + { + return null; + } + + var start = prefix.Length; + var end = start; + while (end < model.Length && char.IsDigit(model[end])) + { + end++; + } + + return end > start && int.TryParse(model.Substring(start, end - start), NumberStyles.None, CultureInfo.InvariantCulture, out var value) + ? value + : null; + } + + private static string NormalizeToolCallId(string id) + { + var builder = new StringBuilder(Math.Min(id.Length, 64)); + foreach (var character in id) + { + if (builder.Length >= 64) + { + break; + } + + builder.Append(char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_'); + } + + return builder.Length == 0 ? "call" : builder.ToString(); + } + + private static bool IsGemini3Pro(string model) => + model.ToLowerInvariant().StartsWith("gemini-3", StringComparison.Ordinal) + && model.Contains("pro", StringComparison.OrdinalIgnoreCase); + + private static bool IsGemini3Flash(string model) + { + var lower = model.ToLowerInvariant(); + return (lower.StartsWith("gemini-3", StringComparison.Ordinal) && lower.Contains("flash", StringComparison.Ordinal)) + || lower is "gemini-flash-latest" or "gemini-flash-lite-latest"; + } + + private static bool IsGemma4(string model) => + model.Contains("gemma-4", StringComparison.OrdinalIgnoreCase) + || model.Contains("gemma4", StringComparison.OrdinalIgnoreCase); + + private static object? SanitizeOpenApiSchema(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var result = new Dictionary(); + foreach (var property in value.EnumerateObject()) + { + if (property.Name is "$schema" or "$id" or "$anchor" or "$dynamicAnchor" or "$vocabulary" or "$comment" or "$defs" or "definitions") + { + continue; + } + + result[property.Name] = SanitizeOpenApiSchema(property.Value); + } + + return result; + } + + if (value.ValueKind == JsonValueKind.Array) + { + return value.EnumerateArray().Select(SanitizeOpenApiSchema).ToArray(); + } + + return value.Clone(); + } + + private static object ParseJsonOrString(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return value; + } + } + + private static string SanitizeUnicode(string value) + { + StringBuilder? builder = null; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (char.IsHighSurrogate(character) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + if (builder is not null) + { + builder.Append(character); + builder.Append(value[++index]); + } + else + { + index++; + } + + continue; + } + + if (!char.IsSurrogate(character)) + { + builder?.Append(character); + continue; + } + + builder ??= new StringBuilder(value.Substring(0, index)); + builder.Append('\uFFFD'); + } + + return builder?.ToString() ?? value; + } + + private static async Task ReadBoundedAsync( + HttpContent content, + int maximumCharacters, + CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var buffer = new char[Math.Min(4096, maximumCharacters)]; + var builder = new StringBuilder(); + while (builder.Length < maximumCharacters) + { + var read = await reader.ReadAsync(buffer, 0, Math.Min(buffer.Length, maximumCharacters - builder.Length)) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + builder.Append(buffer, 0, read); + } + + return builder.ToString(); + } + + private static async IAsyncEnumerable ReadSseDataAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var data = new StringBuilder(); + await foreach (var line in ReadBoundedLinesAsync(reader, maximumCharacters, cancellationToken)) + { + if (line.Length == 0) + { + if (data.Length > 0) + { + yield return data.ToString(); + } + + data.Clear(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + if (data.Length > 0) + { + data.Append('\n'); + } + + data.Append(line.Substring(5).TrimStart()); + if (data.Length > maximumCharacters) + { + throw new InvalidDataException("A Google SSE event exceeded the configured size limit."); + } + } + } + + if (data.Length > 0) + { + yield return data.ToString(); + } + } + + private static async IAsyncEnumerable ReadBoundedLinesAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(Math.Min(4096, maximumCharacters + 1)); + var line = new StringBuilder(); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (read == 0) + { + if (line.Length > 0) + { + yield return TrimCarriageReturn(line); + } + + yield break; + } + + for (var index = 0; index < read; index++) + { + if (buffer[index] == '\n') + { + yield return TrimCarriageReturn(line); + line.Clear(); + } + else + { + line.Append(buffer[index]); + if (line.Length > maximumCharacters) + { + throw new InvalidDataException("A Google SSE line exceeded the configured size limit."); + } + } + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string TrimCarriageReturn(StringBuilder line) + { + var length = line.Length; + if (length > 0 && line[length - 1] == '\r') + { + length--; + } + + return line.ToString(0, length); + } + + private static void ValidateOptions(GoogleGenerativeProviderOptions options) + { + if (!Enum.IsDefined(typeof(GoogleApiFlavor), options.Flavor) + || !Enum.IsDefined(typeof(GoogleCredentialPlacement), options.CredentialPlacement) + || options.ToolChoice is { } choice && !Enum.IsDefined(typeof(GoogleToolChoice), choice)) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ProviderId) || string.IsNullOrWhiteSpace(options.ApiId)) + { + throw new ArgumentException("Google provider and API identifiers are required.", nameof(options)); + } + + if (!options.AllowInsecureHttp && options.Endpoint.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The Google endpoint must use HTTPS.", nameof(options)); + } + + if (options.MaxEventCharacters <= 0 + || options.MaxErrorCharacters <= 0 + || options.MaxRequestBytes <= 0 + || options.MaxResponseCharacters <= 0 + || options.MaxToolCallsPerResponse <= 0 + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "Google protocol limits must be positive."); + } + + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); + } + + private sealed class ProjectedContent + { + public ProjectedContent(string role, IEnumerable parts) + { + Role = role; + Parts = parts.ToList(); + } + + public string Role { get; } + + public List Parts { get; } + } +} diff --git a/src/OpenGameAgent.Providers.Google/GoogleStreamState.cs b/src/OpenGameAgent.Providers.Google/GoogleStreamState.cs new file mode 100644 index 0000000..4269957 --- /dev/null +++ b/src/OpenGameAgent.Providers.Google/GoogleStreamState.cs @@ -0,0 +1,463 @@ +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Google; + +internal sealed class GoogleStreamState +{ + private readonly string _requestModel; + private readonly string _providerId; + private readonly string _apiId; + private readonly int _maximumCharacters; + private readonly int _maximumToolCalls; + private readonly List _blocks = new(); + private readonly HashSet _toolCallIds = new(StringComparer.Ordinal); + private long _characters; + private Block? _currentTextBlock; + private string? _responseId; + private ModelStopReason _stopReason = ModelStopReason.Pending; + private string? _rawStopReason; + private string? _errorMessage; + private ModelUsage _usage = new(); + private long _generatedToolCallId; + + public GoogleStreamState( + string requestModel, + string providerId, + string apiId, + int maximumCharacters, + int maximumToolCalls) + { + _requestModel = requestModel; + _providerId = providerId; + _apiId = apiId; + _maximumCharacters = maximumCharacters; + _maximumToolCalls = maximumToolCalls; + } + + public ModelResponse Partial() => BuildResponse(ModelStopReason.Pending, null); + + public IReadOnlyList Apply(string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + RequireKind(root, JsonValueKind.Object, "A Google stream chunk must be a JSON object."); + EnsureUnambiguous(root); + if (root.TryGetProperty("error", out var error)) + { + throw new InvalidDataException("Google stream error: " + ReadError(error)); + } + + var updates = new List(); + var responseId = OptionalString(root, "responseId"); + if (!string.IsNullOrWhiteSpace(responseId)) + { + _responseId ??= responseId; + } + + if (root.TryGetProperty("candidates", out var candidates)) + { + RequireKind(candidates, JsonValueKind.Array, "Google candidates must be an array."); + if (candidates.GetArrayLength() > 0) + { + ApplyCandidate(candidates[0], updates); + } + } + + if (root.TryGetProperty("usageMetadata", out var usage)) + { + RequireKind(usage, JsonValueKind.Object, "Google usageMetadata must be an object."); + ReadUsage(usage); + } + + return updates; + } + catch (JsonException exception) + { + throw new InvalidDataException("The Google stream contained invalid JSON.", exception); + } + catch (InvalidOperationException exception) + { + throw new InvalidDataException("The Google stream did not match the expected response shape.", exception); + } + } + + public IReadOnlyList CloseOpenBlock() + { + var updates = new List(); + CloseCurrent(updates); + return updates; + } + + public ModelResponse Complete() + { + if (_currentTextBlock is not null) + { + throw new InvalidDataException("The Google stream completed before its final content block was closed."); + } + + if (_stopReason == ModelStopReason.Pending) + { + throw new InvalidDataException("The Google stream ended without a finish reason."); + } + + return BuildResponse(_stopReason, _errorMessage); + } + + private void ApplyCandidate(JsonElement candidate, ICollection updates) + { + RequireKind(candidate, JsonValueKind.Object, "A Google candidate must be an object."); + if (candidate.TryGetProperty("content", out var content) + && content.ValueKind != JsonValueKind.Null + && content.TryGetProperty("parts", out var parts)) + { + RequireKind(parts, JsonValueKind.Array, "Google candidate parts must be an array."); + foreach (var part in parts.EnumerateArray()) + { + ApplyPart(part, updates); + } + } + + var finishReason = OptionalString(candidate, "finishReason"); + if (!string.IsNullOrWhiteSpace(finishReason)) + { + _rawStopReason = finishReason; + _stopReason = MapStopReason(finishReason!); + if (_blocks.Any(block => block.Kind == BlockKind.Tool)) + { + _stopReason = ModelStopReason.ToolUse; + } + + if (_stopReason == ModelStopReason.Error) + { + _errorMessage = "Provider stopped with: " + finishReason; + } + } + } + + private void ApplyPart(JsonElement part, ICollection updates) + { + RequireKind(part, JsonValueKind.Object, "A Google content part must be an object."); + if (part.TryGetProperty("text", out var textValue)) + { + if (textValue.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("Google text content must be a string."); + } + + var isReasoning = OptionalBoolean(part, "thought") == true; + var kind = isReasoning ? BlockKind.Reasoning : BlockKind.Text; + if (_currentTextBlock is null || _currentTextBlock.Kind != kind) + { + CloseCurrent(updates); + _currentTextBlock = new Block(kind); + _blocks.Add(_currentTextBlock); + updates.Add(ModelStreamEvent.Update( + isReasoning ? ModelStreamEventKind.ReasoningStarted : ModelStreamEventKind.TextStarted, + Partial(), + contentIndex: _blocks.Count - 1)); + } + + var text = textValue.GetString() ?? string.Empty; + Append(_currentTextBlock.Text, text); + var signature = OptionalString(part, "thoughtSignature"); + if (!string.IsNullOrEmpty(signature)) + { + _currentTextBlock.Signature = signature; + } + + updates.Add(ModelStreamEvent.Update( + isReasoning ? ModelStreamEventKind.ReasoningDelta : ModelStreamEventKind.TextDelta, + Partial(), + text, + _blocks.Count - 1)); + } + + if (part.TryGetProperty("functionCall", out var functionCall)) + { + CloseCurrent(updates); + RequireKind(functionCall, JsonValueKind.Object, "A Google functionCall must be an object."); + if (_blocks.Count(block => block.Kind == BlockKind.Tool) >= _maximumToolCalls) + { + throw new InvalidDataException("The Google response exceeded the configured tool-call limit."); + } + + var name = RequiredString(functionCall, "name"); + var id = OptionalString(functionCall, "id"); + if (string.IsNullOrWhiteSpace(id) || !_toolCallIds.Add(id!)) + { + do + { + id = SanitizeToolCallId(name) + "_" + (++_generatedToolCallId).ToString(System.Globalization.CultureInfo.InvariantCulture); + } + while (!_toolCallIds.Add(id)); + } + + var arguments = "{}"; + if (functionCall.TryGetProperty("args", out var args)) + { + RequireKind(args, JsonValueKind.Object, "Google function-call arguments must be an object."); + arguments = args.GetRawText(); + AddCharacters(arguments.Length); + } + + var block = new Block(BlockKind.Tool) + { + Id = id, + Name = name, + ArgumentsJson = arguments, + Signature = OptionalString(part, "thoughtSignature"), + }; + _blocks.Add(block); + var contentIndex = _blocks.Count - 1; + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallStarted, + Partial(), + contentIndex: contentIndex, + toolCallId: id, + toolName: name)); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + arguments, + contentIndex, + id, + name)); + var partial = Partial(); + var toolCall = partial.Content[contentIndex] as ToolCallContent + ?? throw new InvalidDataException("A completed Google function call did not produce a tool call."); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + partial, + contentIndex: contentIndex, + toolCall: toolCall)); + } + } + + private void CloseCurrent(ICollection updates) + { + if (_currentTextBlock is null) + { + return; + } + + var block = _currentTextBlock; + var contentIndex = _blocks.IndexOf(block); + updates.Add(ModelStreamEvent.Update( + block.Kind == BlockKind.Reasoning + ? ModelStreamEventKind.ReasoningEnded + : ModelStreamEventKind.TextEnded, + Partial(), + contentIndex: contentIndex, + content: block.Text.ToString())); + _currentTextBlock = null; + } + + private ModelResponse BuildResponse(ModelStopReason reason, string? errorMessage) + { + var content = new List(_blocks.Count); + foreach (var block in _blocks) + { + switch (block.Kind) + { + case BlockKind.Text: + content.Add(new TextContent(block.Text.ToString(), block.Signature)); + break; + case BlockKind.Reasoning: + content.Add(new ReasoningContent(block.Text.ToString(), block.Signature)); + break; + case BlockKind.Tool: + content.Add(new ToolCallContent( + block.Id!, + block.Name!, + block.ArgumentsJson!, + block.Signature)); + break; + } + } + + return new ModelResponse( + content, + reason, + _usage, + errorMessage, + _providerId, + _apiId, + _requestModel, + _responseId, + _rawStopReason); + } + + private void ReadUsage(JsonElement usage) + { + var prompt = OptionalInt64(usage, "promptTokenCount"); + var cached = OptionalInt64(usage, "cachedContentTokenCount"); + var candidates = OptionalInt64(usage, "candidatesTokenCount"); + var thoughts = OptionalInt64(usage, "thoughtsTokenCount"); + var input = Math.Max(0, prompt - cached); + var output = checked(candidates + thoughts); + _usage = new ModelUsage(input, output, cached, reasoningTokens: thoughts); + } + + private void Append(StringBuilder builder, string value) + { + AddCharacters(value.Length); + builder.Append(value); + } + + private void AddCharacters(int count) + { + _characters = checked(_characters + count); + if (_characters > _maximumCharacters) + { + throw new InvalidDataException("The Google response exceeded the configured character limit."); + } + } + + private static ModelStopReason MapStopReason(string reason) => reason switch + { + "STOP" => ModelStopReason.Stop, + "MAX_TOKENS" => ModelStopReason.Length, + _ => ModelStopReason.Error, + }; + + private static string ReadError(JsonElement error) + { + if (error.ValueKind == JsonValueKind.String) + { + return error.GetString() ?? "Unknown error"; + } + + if (error.ValueKind == JsonValueKind.Object) + { + return OptionalString(error, "message") ?? error.GetRawText(); + } + + return error.GetRawText(); + } + + private static string SanitizeToolCallId(string value) + { + var builder = new StringBuilder(Math.Min(value.Length, 48)); + foreach (var character in value) + { + if (builder.Length >= 48) + { + break; + } + + builder.Append(char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_'); + } + + return builder.Length == 0 ? "call" : builder.ToString(); + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("Google JSON objects cannot contain duplicate property names."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private static void RequireKind(JsonElement value, JsonValueKind kind, string message) + { + if (value.ValueKind != kind) + { + throw new InvalidDataException(message); + } + } + + private static string RequiredString(JsonElement value, string property) + { + var result = OptionalString(value, property); + return string.IsNullOrWhiteSpace(result) + ? throw new InvalidDataException("Google field '" + property + "' must be a non-empty string.") + : result!; + } + + private static string? OptionalString(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var result) || result.ValueKind == JsonValueKind.Null) + { + return null; + } + + return result.ValueKind == JsonValueKind.String + ? result.GetString() + : throw new InvalidDataException("Google field '" + property + "' must be a string."); + } + + private static bool? OptionalBoolean(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var result) || result.ValueKind == JsonValueKind.Null) + { + return null; + } + + return result.ValueKind is JsonValueKind.True or JsonValueKind.False + ? result.GetBoolean() + : throw new InvalidDataException("Google field '" + property + "' must be a boolean."); + } + + private static long OptionalInt64(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var result) || result.ValueKind == JsonValueKind.Null) + { + return 0; + } + + if (result.ValueKind != JsonValueKind.Number || !result.TryGetInt64(out var number) || number < 0) + { + throw new InvalidDataException("Google field '" + property + "' must be a non-negative integer."); + } + + return number; + } + + private enum BlockKind + { + Text, + Reasoning, + Tool, + } + + private sealed class Block + { + public Block(BlockKind kind) + { + Kind = kind; + } + + public BlockKind Kind { get; } + + public StringBuilder Text { get; } = new(); + + public string? Signature { get; set; } + + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? ArgumentsJson { get; set; } + } +} diff --git a/src/OpenGameAgent.Providers.Google/GoogleVertexCredentials.cs b/src/OpenGameAgent.Providers.Google/GoogleVertexCredentials.cs new file mode 100644 index 0000000..8d570f4 --- /dev/null +++ b/src/OpenGameAgent.Providers.Google/GoogleVertexCredentials.cs @@ -0,0 +1,67 @@ +using Google.Apis.Auth.OAuth2; + +namespace OpenGameAgent.Providers.Google; + +public static class GoogleVertexCredentials +{ + public const string CloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"; + + public static GoogleCredentialProvider ApplicationDefault(params string[] scopes) + { + var selectedScopes = scopes is { Length: > 0 } + ? scopes.ToArray() + : new[] { CloudPlatformScope }; + if (selectedScopes.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("Google OAuth scopes must be non-empty.", nameof(scopes)); + } + + var gate = new SemaphoreSlim(1, 1); + GoogleCredential? credential = null; + return async cancellationToken => + { + if (credential is null) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (credential is null) + { + var discovered = await GoogleCredential.GetApplicationDefaultAsync(cancellationToken) + .ConfigureAwait(false); + credential = discovered.IsCreateScopedRequired + ? discovered.CreateScoped(selectedScopes) + : discovered; + } + } + finally + { + gate.Release(); + } + } + + return await credential.UnderlyingCredential + .GetAccessTokenForRequestAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + }; + } + + public static Uri Endpoint(string project, string location) + { + if (string.IsNullOrWhiteSpace(project)) + { + throw new ArgumentException("A Google Cloud project is required.", nameof(project)); + } + + if (string.IsNullOrWhiteSpace(location)) + { + throw new ArgumentException("A Google Cloud location is required.", nameof(location)); + } + + var host = Uri.EscapeDataString(location) + "-aiplatform.googleapis.com"; + var path = "/v1/projects/" + Uri.EscapeDataString(project) + + "/locations/" + Uri.EscapeDataString(location) + + "/publishers/google/models/{model}:streamGenerateContent"; + return new Uri("https://" + host + path, UriKind.Absolute); + } +} diff --git a/src/OpenGameAgent.Providers.Google/OpenGameAgent.Providers.Google.csproj b/src/OpenGameAgent.Providers.Google/OpenGameAgent.Providers.Google.csproj new file mode 100644 index 0000000..ad4a058 --- /dev/null +++ b/src/OpenGameAgent.Providers.Google/OpenGameAgent.Providers.Google.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + OpenGameAgent.Providers.Google + Native Google Gemini and Vertex AI transports for OpenGameAgent. + + + + + + + + + + diff --git a/src/OpenGameAgent.Providers.Google/packages.lock.json b/src/OpenGameAgent.Providers.Google/packages.lock.json new file mode 100644 index 0000000..9ceaf03 --- /dev/null +++ b/src/OpenGameAgent.Providers.Google/packages.lock.json @@ -0,0 +1,123 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "Google.Apis.Auth": { + "type": "Direct", + "requested": "[1.75.0, )", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProvider.cs b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProvider.cs new file mode 100644 index 0000000..15dd486 --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProvider.cs @@ -0,0 +1,783 @@ +using System.Collections.ObjectModel; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.MessageGateway; + +public sealed class MessageGatewayProvider : IModelProvider, IModelProviderCapabilities +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + private readonly MessageGatewaySettings _settings; + private readonly IReadOnlyCollection _supportedApis; + + public MessageGatewayProvider(MessageGatewayProviderOptions options) + { + _settings = new MessageGatewaySettings(options ?? throw new ArgumentNullException(nameof(options))); + _supportedApis = Array.AsReadOnly(new[] { _settings.ApiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => false; + + public bool SupportsDeferredResponses => false; + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) => + StreamWithBoundaryAsync( + request ?? throw new ArgumentNullException(nameof(request)), + cancellationToken); + + private async IAsyncEnumerable StreamWithBoundaryAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + IAsyncEnumerator? enumerator = null; + Exception? setupError = null; + try + { + enumerator = StreamCoreAsync(request, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + setupError = exception; + } + + if (setupError is not null) + { + yield return Failure(request, setupError); + yield break; + } + + var terminal = false; + try + { + while (!terminal) + { + bool moved = false; + ModelStreamEvent? current = null; + Exception? moveError = null; + try + { + moved = await enumerator!.MoveNextAsync().ConfigureAwait(false); + if (moved) + { + current = enumerator.Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + moveError = exception; + } + + if (moveError is not null) + { + if (moveError is ModelProviderException) + { + throw moveError; + } + + terminal = true; + yield return Failure(request, moveError); + continue; + } + + if (!moved) + { + terminal = true; + yield return Failure( + request, + new InvalidDataException("The message gateway stream ended without a terminal event.")); + continue; + } + + if (current is null) + { + terminal = true; + yield return Failure( + request, + new InvalidDataException("The message gateway emitted a null event.")); + continue; + } + + terminal = current.IsTerminal; + yield return current; + } + } + finally + { + try + { + await enumerator!.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Cleanup must not replace a terminal result or caller cancellation. + } + } + } + + private async IAsyncEnumerable StreamCoreAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("The message gateway uses a server-sent-event transport."); + } + + var projected = MessageGatewayWire.ProjectRequest(request, _settings); + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + RequestEndpoint(_settings.Endpoint, projected.Debug)); + foreach (var pair in _settings.Headers) + { + if (!httpRequest.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) + { + throw new InvalidOperationException("A configured message gateway header could not be applied."); + } + } + + string? resolvedAccessToken = null; + if (!httpRequest.Headers.Contains("Authorization")) + { + resolvedAccessToken = _settings.GetAccessTokenAsync is null + ? _settings.AccessToken + : await AwaitWithCancellation( + _settings.GetAccessTokenAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + MessageGatewaySettings.ValidateCredential( + resolvedAccessToken, + nameof(MessageGatewayProviderOptions.AccessToken)); + if (string.IsNullOrEmpty(resolvedAccessToken)) + { + throw new InvalidOperationException("No access token is configured for the message gateway."); + } + + if (!httpRequest.Headers.TryAddWithoutValidation("Authorization", "Bearer " + resolvedAccessToken)) + { + throw new InvalidOperationException("The message gateway authorization header could not be applied."); + } + } + + var redactor = new MessageGatewaySecretRedactor(_settings, resolvedAccessToken); + + httpRequest.Headers.Accept.Clear(); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + httpRequest.Content = new ByteArrayContent(projected.Payload); + httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + + HttpResponseMessage response; + try + { + response = await AwaitOwnedWithCancellation( + _settings.HttpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is HttpRequestException or IOException or OperationCanceledException) + { + throw CreateTransportFailure(exception, redactor); + } + + using (response) + { + var observation = RedactedObservation(request, response, redactor); + await ProviderResponseObserverRunner.NotifyAsync( + _settings.ResponseObserver, + observation, + _settings.ResponseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + string body; + try + { + body = await ReadBoundedTextAsync( + response.Content, + _settings.MaxErrorCharacters, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + body = string.Empty; + } + + throw CreateHttpFailure(response, observation, body, redactor); + } + + if (!string.Equals( + response.Content.Headers.ContentType?.MediaType, + "text/event-stream", + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("The message gateway response must use text/event-stream."); + } + + var streamTask = response.Content.ReadAsStreamAsync(); + using var pendingRegistration = cancellationToken.Register(response.Content.Dispose); + using var responseStream = await AwaitOwnedWithCancellation(streamTask, cancellationToken).ConfigureAwait(false); + using var cancellationRegistration = cancellationToken.Register(responseStream.Dispose); + using var boundedStream = new BoundedReadStream(responseStream, _settings.MaxResponseBytes); + using var reader = new StreamReader(boundedStream, StrictUtf8, false, 4096, leaveOpen: false); + var state = new MessageGatewayStreamState(request, _settings, redactor); + await foreach (var frame in ReadFramesAsync(reader, cancellationToken)) + { + if (frame.Length == 0 || string.Equals(frame, "[DONE]", StringComparison.Ordinal)) + { + continue; + } + + var decoded = state.Apply(frame); + if (decoded.IsTerminal) + { + yield return decoded; + yield break; + } + + yield return decoded; + } + + cancellationToken.ThrowIfCancellationRequested(); + state.EnsureComplete(); + throw new InvalidDataException("The message gateway stream ended without a terminal event."); + } + } + + private async IAsyncEnumerable ReadFramesAsync( + StreamReader reader, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var data = new StringBuilder(); + var dataBytes = 0; + var hasData = false; + var eventCount = 0; + await foreach (var line in ReadLinesAsync(reader, cancellationToken)) + { + if (line.Length == 0) + { + if (!hasData) + { + continue; + } + + eventCount++; + if (eventCount > _settings.MaxEvents) + { + throw new InvalidDataException("The message gateway exceeded its event-count limit."); + } + + yield return data.ToString(); + data.Clear(); + dataBytes = 0; + hasData = false; + continue; + } + + if (line[0] == ':') + { + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + var value = line.Substring(5); + if (value.Length > 0 && value[0] == ' ') + { + value = value.Substring(1); + } + + var valueBytes = StrictUtf8.GetByteCount(value); + var separatorBytes = hasData ? 1 : 0; + if ((long)dataBytes + separatorBytes + valueBytes > _settings.MaxEventBytes) + { + throw new InvalidDataException("A message gateway event exceeded its size limit."); + } + + if (hasData) + { + data.Append('\n'); + } + + data.Append(value); + dataBytes += separatorBytes + valueBytes; + hasData = true; + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal) + || line.StartsWith("id:", StringComparison.Ordinal) + || line.StartsWith("retry:", StringComparison.Ordinal)) + { + continue; + } + + throw new InvalidDataException("The message gateway stream contains an unsupported SSE field."); + } + + if (hasData) + { + eventCount++; + if (eventCount > _settings.MaxEvents) + { + throw new InvalidDataException("The message gateway exceeded its event-count limit."); + } + + yield return data.ToString(); + } + } + + private async IAsyncEnumerable ReadLinesAsync( + StreamReader reader, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = new char[Math.Min(4096, _settings.MaxEventBytes + 1)]; + var line = new StringBuilder(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + int read; + try + { + read = await AwaitWithCancellation( + reader.ReadAsync(buffer, 0, buffer.Length), + cancellationToken) + .ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + catch (IOException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + if (read == 0) + { + if (line.Length > 0) + { + yield return TrimCarriageReturn(line); + } + + yield break; + } + + for (var index = 0; index < read; index++) + { + if (buffer[index] == '\n') + { + yield return TrimCarriageReturn(line); + line.Clear(); + } + else + { + line.Append(buffer[index]); + if (line.Length > _settings.MaxEventBytes) + { + throw new InvalidDataException("A message gateway SSE line exceeded its size limit."); + } + } + } + } + } + + private static string TrimCarriageReturn(StringBuilder line) + { + var length = line.Length; + if (length > 0 && line[length - 1] == '\r') + { + length--; + } + + return line.ToString(0, length); + } + + private static Uri RequestEndpoint(Uri endpoint, bool debug) + { + if (!debug) + { + return endpoint; + } + + var builder = new UriBuilder(endpoint); + var pairs = builder.Query.TrimStart('?') + .Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries) + .Where(pair => !string.Equals( + Uri.UnescapeDataString(pair.Split(new[] { '=' }, 2)[0]), + "debug", + StringComparison.OrdinalIgnoreCase)) + .ToList(); + pairs.Add("debug=1"); + builder.Query = string.Join("&", pairs); + return builder.Uri; + } + + private ModelProviderException CreateHttpFailure( + HttpResponseMessage response, + ProviderResponseObservation observation, + string body, + MessageGatewaySecretRedactor redactor) + { + var code = default(string); + var detail = default(string); + var structuredBody = LooksLikeJson(body); + try + { + using var document = MessageGatewayJson.Parse(body, _settings.MaxJsonDepth); + structuredBody = true; + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object) + { + code = OptionalErrorString(error, "code", 256); + detail = OptionalErrorString(error, "message", _settings.MaxErrorCharacters); + } + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + // A non-JSON error body remains available only as bounded, sanitized text. + } + + code = code is null ? null : redactor.Sanitize(code, 256); + var boundedBody = redactor.Sanitize( + detail ?? (structuredBody ? string.Empty : body), + _settings.MaxErrorCharacters); + var status = (int)response.StatusCode; + var message = $"The message gateway returned HTTP {status} ({redactor.Sanitize(response.ReasonPhrase ?? "error", 256)})."; + if (boundedBody.Length > 0) + { + message += " " + boundedBody; + } + + if (!string.IsNullOrEmpty(code)) + { + message += " (" + code + ")"; + } + + var diagnosticData = JsonSerializer.Serialize(new + { + version = 1, + statusCode = status, + code, + metadata = observation.Metadata, + }); + var retry = ProviderHttpRetryMetadata.FromResponse(response); + return new ModelProviderException( + message, + new[] + { + new ModelDiagnostic( + "message_gateway_response_failure", + "The message gateway returned an unsuccessful HTTP response.", + ModelDiagnosticSeverity.Error, + diagnosticData), + }, + retry.IsTransient, + retry.RetryAfter, + status); + } + + private ModelStreamEvent Failure(ModelRequest request, Exception exception) + { + var redactor = new MessageGatewaySecretRedactor(_settings, _settings.AccessToken); + var message = redactor.Sanitize( + string.IsNullOrWhiteSpace(exception.Message) ? exception.GetType().Name : exception.Message, + 4096); + var diagnostics = exception is ModelProviderException providerException + && providerException.Diagnostics.Count > 0 + ? providerException.Diagnostics + : new[] + { + new ModelDiagnostic( + "message_gateway_error", + message, + ModelDiagnosticSeverity.Error), + }; + return ModelStreamEvent.Terminal(new ModelResponse( + Array.Empty(), + ModelStopReason.Error, + errorMessage: message, + provider: _settings.ProviderId, + api: _settings.ApiId, + responseModel: request.Model, + diagnostics: diagnostics)); + } + + private ModelProviderException CreateTransportFailure( + Exception exception, + MessageGatewaySecretRedactor redactor) + { + var detail = redactor.Sanitize( + string.IsNullOrWhiteSpace(exception.Message) ? exception.GetType().Name : exception.Message, + 1_024); + var message = "The message gateway transport failed."; + if (detail.Length > 0) + { + message += " " + detail; + } + + return new ModelProviderException( + message, + new[] + { + new ModelDiagnostic( + "message_gateway_transport_failure", + "The message gateway transport failed before a response was received.", + ModelDiagnosticSeverity.Error), + }, + isTransient: true, + innerException: exception); + } + + private ProviderResponseObservation RedactedObservation( + ModelRequest request, + HttpResponseMessage response, + MessageGatewaySecretRedactor redactor) + { + var observation = ProviderResponseObservation.FromHttpResponse( + _settings.ProviderId, + _settings.ApiId, + request.Model, + response); + var metadata = observation.Metadata.ToDictionary( + pair => pair.Key, + pair => redactor.Sanitize(pair.Value, 1_024), + StringComparer.OrdinalIgnoreCase); + return ProviderResponseObservation.FromResponseMetadata( + _settings.ProviderId, + _settings.ApiId, + request.Model, + observation.StatusCode, + metadata); + } + + private static string? OptionalErrorString(JsonElement value, string property, int maximumCharacters) + { + if (!value.TryGetProperty(property, out var element) + || element.ValueKind != JsonValueKind.String + || element.GetString() is not { } result + || result.Length > maximumCharacters) + { + return null; + } + + return result; + } + + private static bool LooksLikeJson(string value) + { + var trimmed = value.TrimStart(); + return trimmed.StartsWith("{", StringComparison.Ordinal) + || trimmed.StartsWith("[", StringComparison.Ordinal) + || trimmed.StartsWith("\"", StringComparison.Ordinal); + } + + private static async Task ReadBoundedTextAsync( + HttpContent content, + int maximumCharacters, + CancellationToken cancellationToken) + { + using var source = await AwaitOwnedWithCancellation(content.ReadAsStreamAsync(), cancellationToken) + .ConfigureAwait(false); + using var cancellationRegistration = cancellationToken.Register(source.Dispose); + using var bounded = new BoundedReadStream( + source, + Math.Min(100_000_000L, Math.Max(4L, maximumCharacters * 4L))); + using var reader = new StreamReader(bounded, StrictUtf8, false, 4096, leaveOpen: false); + var buffer = new char[Math.Min(4096, maximumCharacters)]; + var result = new StringBuilder(); + while (result.Length < maximumCharacters) + { + var read = await AwaitWithCancellation( + reader.ReadAsync( + buffer, + 0, + Math.Min(buffer.Length, maximumCharacters - result.Length)), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + result.Append(buffer, 0, read); + } + + return result.ToString(); + } + + private static async ValueTask AwaitWithCancellation( + ValueTask operation, + CancellationToken cancellationToken) => + await AwaitWithCancellation(operation.AsTask(), cancellationToken).ConfigureAwait(false); + + private static async Task AwaitWithCancellation( + Task operation, + CancellationToken cancellationToken) + { + if (operation.IsCompleted) + { + return await operation.ConfigureAwait(false); + } + + var cancellation = Task.Delay(Timeout.Infinite, cancellationToken); + if (await Task.WhenAny(operation, cancellation).ConfigureAwait(false) != operation) + { + Observe(operation); + cancellationToken.ThrowIfCancellationRequested(); + } + + return await operation.ConfigureAwait(false); + } + + private static async Task AwaitOwnedWithCancellation( + Task operation, + CancellationToken cancellationToken) + where T : IDisposable + { + if (operation.IsCompleted) + { + return await operation.ConfigureAwait(false); + } + + var cancellation = Task.Delay(Timeout.Infinite, cancellationToken); + if (await Task.WhenAny(operation, cancellation).ConfigureAwait(false) != operation) + { + ObserveOwned(operation); + cancellationToken.ThrowIfCancellationRequested(); + } + + return await operation.ConfigureAwait(false); + } + + private static void Observe(Task task) + { + _ = ObserveAsync(task); + } + + private static void ObserveOwned(Task task) + where T : IDisposable + { + _ = ObserveOwnedAsync(task); + } + + private static async Task ObserveAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // Detached completion cannot affect the canceled request. + } + } + + private static async Task ObserveOwnedAsync(Task task) + where T : IDisposable + { + try + { + (await task.ConfigureAwait(false)).Dispose(); + } + catch + { + // Detached completion cannot affect the canceled request. + } + } + + private sealed class BoundedReadStream : Stream + { + private readonly Stream _inner; + private readonly long _maximumBytes; + private long _bytesRead; + + public BoundedReadStream(Stream inner, long maximumBytes) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _maximumBytes = maximumBytes > 0 + ? maximumBytes + : throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + var read = _inner.Read(buffer, offset, BoundedCount(count)); + Account(read); + return read; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + var read = await _inner.ReadAsync(buffer, offset, BoundedCount(count), cancellationToken) + .ConfigureAwait(false); + Account(read); + return read; + } + + private int BoundedCount(int requested) + { + var remaining = _maximumBytes - _bytesRead; + return (int)Math.Min(requested, Math.Max(1L, remaining + 1L)); + } + + private void Account(int read) + { + _bytesRead += read; + if (_bytesRead > _maximumBytes) + { + throw new InvalidDataException("The message gateway response exceeded its size limit."); + } + } + } +} diff --git a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProviderOptions.cs b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProviderOptions.cs new file mode 100644 index 0000000..df8cf84 --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayProviderOptions.cs @@ -0,0 +1,258 @@ +using System.Collections.ObjectModel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.MessageGateway; + +public delegate ValueTask MessageGatewayAccessTokenProvider(CancellationToken cancellationToken); + +public enum MessageGatewayToolChoiceMode +{ + Auto, + None, + Required, + Function, +} + +public static class MessageGatewayParameterKeys +{ + public const string Debug = "message-gateway.debug"; + public const string ToolChoice = "message-gateway.tool-choice"; +} + +public sealed class MessageGatewayProviderOptions +{ + public MessageGatewayProviderOptions(HttpClient httpClient, Uri baseUrl) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + BaseUrl = baseUrl ?? throw new ArgumentNullException(nameof(baseUrl)); + } + + public HttpClient HttpClient { get; } + + public Uri BaseUrl { get; } + + public string? AccessToken { get; set; } + + public MessageGatewayAccessTokenProvider? GetAccessTokenAsync { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public string ProviderId { get; set; } = "message-gateway"; + + public string ApiId { get; set; } = "message-gateway"; + + public bool Debug { get; set; } + + public MessageGatewayToolChoiceMode? ToolChoice { get; set; } + + public string? ToolName { get; set; } + + public bool AllowInsecureHttp { get; set; } + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseBytes { get; set; } = 32_000_000; + + public int MaxEventBytes { get; set; } = 4_000_000; + + public int MaxErrorCharacters { get; set; } = 64_000; + + public int MaxEvents { get; set; } = 100_000; + + public int MaxJsonDepth { get; set; } = 128; + + public int MaxContentBlocks { get; set; } = 10_000; + + public int MaxContentCharacters { get; set; } = 16_000_000; + + public int MaxToolCalls { get; set; } = 1_000; + + public int MaxPartialSnapshotWork { get; set; } = 64_000_000; +} + +internal sealed class MessageGatewaySettings +{ + public MessageGatewaySettings(MessageGatewayProviderOptions options) + { + if (options.BaseUrl is null + || !options.BaseUrl.IsAbsoluteUri + || options.BaseUrl.UserInfo.Length > 0 + || options.BaseUrl.Fragment.Length > 0 + || options.BaseUrl.Scheme != Uri.UriSchemeHttp && options.BaseUrl.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException( + "The message gateway base URL must be an absolute HTTP or HTTPS URL without embedded credentials or a fragment.", + nameof(options)); + } + + if (options.BaseUrl.Scheme == Uri.UriSchemeHttp + && !options.BaseUrl.IsLoopback + && !options.AllowInsecureHttp) + { + throw new ArgumentException( + "Remote message gateway endpoints must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ProviderId) + || options.ProviderId.Length > 256 + || options.ProviderId.Any(char.IsControl) + || string.IsNullOrWhiteSpace(options.ApiId) + || options.ApiId.Length > 256 + || options.ApiId.Any(char.IsControl) + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000 + || options.MaxRequestBytes is < 2 or > 100_000_000 + || options.MaxResponseBytes is < 2 or > 100_000_000 + || options.MaxEventBytes is < 2 or > 100_000_000 + || options.MaxErrorCharacters is < 1 or > 10_000_000 + || options.MaxEvents is < 1 or > 1_000_000 + || options.MaxJsonDepth is < 1 or > 1_024 + || options.MaxContentBlocks is < 1 or > 100_000 + || options.MaxContentCharacters is < 1 or > 100_000_000 + || options.MaxToolCalls is < 1 or > 100_000 + || options.MaxPartialSnapshotWork is < 1 or > 1_000_000_000) + { + throw new ArgumentException("One or more message gateway identifiers or bounds are invalid.", nameof(options)); + } + + ValidateCredential(options.AccessToken, nameof(options)); + ProviderHeaderGuard.Validate(options.Headers, nameof(options)); + if (options.Headers.Keys.Any(name => + string.Equals(name, "Accept", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Content-Type", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("Accept and Content-Type are controlled by the message gateway transport.", nameof(options)); + } + + if (options.Headers.TryGetValue("Authorization", out var authorization) + && (string.IsNullOrWhiteSpace(authorization) || authorization.Any(char.IsControl))) + { + throw new ArgumentException("A configured message gateway authorization header is invalid.", nameof(options)); + } + + if (options.ToolChoice is { } choice && !Enum.IsDefined(typeof(MessageGatewayToolChoiceMode), choice)) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + if (options.ToolChoice == MessageGatewayToolChoiceMode.Function) + { + RequireToolName(options.ToolName, nameof(options)); + } + else if (options.ToolName is not null) + { + throw new ArgumentException("A tool name is valid only for function tool choice.", nameof(options)); + } + + HttpClient = options.HttpClient; + Endpoint = BuildEndpoint(options.BaseUrl); + AccessToken = options.AccessToken; + GetAccessTokenAsync = options.GetAccessTokenAsync; + Headers = new ReadOnlyDictionary( + new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase)); + ResponseObserver = options.ResponseObserver; + ResponseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + ProviderId = options.ProviderId; + ApiId = options.ApiId; + Debug = options.Debug; + ToolChoice = options.ToolChoice; + ToolName = options.ToolName; + MaxRequestBytes = options.MaxRequestBytes; + MaxResponseBytes = options.MaxResponseBytes; + MaxEventBytes = Math.Min(options.MaxEventBytes, options.MaxResponseBytes); + MaxErrorCharacters = options.MaxErrorCharacters; + MaxEvents = options.MaxEvents; + MaxJsonDepth = options.MaxJsonDepth; + MaxContentBlocks = options.MaxContentBlocks; + MaxContentCharacters = options.MaxContentCharacters; + MaxToolCalls = options.MaxToolCalls; + MaxPartialSnapshotWork = options.MaxPartialSnapshotWork; + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public string? AccessToken { get; } + + public MessageGatewayAccessTokenProvider? GetAccessTokenAsync { get; } + + public IReadOnlyDictionary Headers { get; } + + public ProviderResponseObserver? ResponseObserver { get; } + + public int ResponseObserverTimeoutMilliseconds { get; } + + public string ProviderId { get; } + + public string ApiId { get; } + + public bool Debug { get; } + + public MessageGatewayToolChoiceMode? ToolChoice { get; } + + public string? ToolName { get; } + + public int MaxRequestBytes { get; } + + public int MaxResponseBytes { get; } + + public int MaxEventBytes { get; } + + public int MaxErrorCharacters { get; } + + public int MaxEvents { get; } + + public int MaxJsonDepth { get; } + + public int MaxContentBlocks { get; } + + public int MaxContentCharacters { get; } + + public int MaxToolCalls { get; } + + public int MaxPartialSnapshotWork { get; } + + public static void ValidateCredential(string? value, string parameterName) + { + if ((value?.Length ?? 0) > 65_536 + || value is { Length: > 0 } && string.IsNullOrWhiteSpace(value) + || value?.Any(character => char.IsControl(character) || char.IsWhiteSpace(character)) == true) + { + throw new ArgumentException( + "A message gateway credential is empty, too large, or contains invalid control characters.", + parameterName); + } + } + + public static string RequireToolName(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 256 + || value.Any(char.IsControl)) + { + throw new ArgumentException("A bounded non-empty tool name is required.", parameterName); + } + + return value; + } + + private static Uri BuildEndpoint(Uri baseUrl) + { + var builder = new UriBuilder(baseUrl); + var path = builder.Path.TrimEnd('/'); + if (!path.EndsWith("/messages", StringComparison.OrdinalIgnoreCase)) + { + path += "/messages"; + } + + builder.Path = path; + return builder.Uri; + } +} diff --git a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewaySecurity.cs b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewaySecurity.cs new file mode 100644 index 0000000..3bf0532 --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewaySecurity.cs @@ -0,0 +1,105 @@ +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Providers.MessageGateway; + +internal sealed class MessageGatewaySecretRedactor +{ + private const string Replacement = "[redacted]"; + private readonly IReadOnlyList _secrets; + + public MessageGatewaySecretRedactor(MessageGatewaySettings settings, string? accessToken) + { + var secrets = new HashSet(StringComparer.Ordinal); + AddSecretVariants(secrets, accessToken); + AddAuthorizationParts(secrets, accessToken); + foreach (var pair in settings.Headers) + { + if (!IsSensitiveHeader(pair.Key)) + { + continue; + } + + AddSecretVariants(secrets, pair.Value); + AddAuthorizationParts(secrets, pair.Value); + } + + _secrets = Array.AsReadOnly(secrets + .OrderByDescending(value => value.Length) + .ToArray()); + } + + public string Sanitize(string value, int maximumCharacters) + { + var redacted = Redact(value); + var builder = new StringBuilder(Math.Min(redacted.Length, maximumCharacters)); + foreach (var character in redacted) + { + if (builder.Length >= maximumCharacters) + { + break; + } + + builder.Append(character is '\r' or '\n' or '\0' || char.IsControl(character) ? ' ' : character); + } + + return builder.ToString(); + } + + private string Redact(string value) + { + var result = value; + foreach (var secret in _secrets) + { + result = result.Replace(secret, Replacement); + } + + return result; + } + + private static bool IsSensitiveHeader(string name) => + name.IndexOf("authorization", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("api-key", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("apikey", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("token", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("secret", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("cookie", StringComparison.OrdinalIgnoreCase) >= 0; + + private static void AddSecretVariants(ISet secrets, string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + secrets.Add(value); + var json = JsonSerializer.Serialize(value); + if (json.Length > 2) + { + secrets.Add(json.Substring(1, json.Length - 2)); + } + + try + { + secrets.Add(Uri.EscapeDataString(value)); + } + catch (UriFormatException) + { + // The exact and JSON-escaped values remain protected. + } + } + + private static void AddAuthorizationParts(ISet secrets, string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + var separator = value.IndexOf(' '); + if (separator >= 0 && separator + 1 < value.Length) + { + AddSecretVariants(secrets, value.Substring(separator + 1)); + } + } +} diff --git a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs new file mode 100644 index 0000000..b01d746 --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs @@ -0,0 +1,1417 @@ +using System.Collections.ObjectModel; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.MessageGateway; + +internal sealed class ProjectedMessageGatewayRequest +{ + public ProjectedMessageGatewayRequest(byte[] payload, bool debug) + { + Payload = payload; + Debug = debug; + } + + public byte[] Payload { get; } + + public bool Debug { get; } +} + +internal static class MessageGatewayWire +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = null, + }; + + public static ProjectedMessageGatewayRequest ProjectRequest( + ModelRequest request, + MessageGatewaySettings settings) + { + if (request.Parameters.Deferred) + { + throw new NotSupportedException("The message gateway does not support deferred responses."); + } + + var normalizedMessages = ProviderTranscript.Normalize( + request.Messages, + settings.ProviderId, + settings.ApiId, + request.Model); + var context = new Dictionary + { + ["messages"] = normalizedMessages.Select(message => ProjectMessage(message, settings, request.Model)).ToArray(), + }; + if (request.SystemPrompt.Length > 0) + { + context["systemPrompt"] = request.SystemPrompt; + } + + if (request.Tools.Count > 0) + { + context["tools"] = request.Tools.Select(ProjectTool).ToArray(); + } + + var options = ProjectOptions(request, settings); + var payload = new Dictionary + { + ["model"] = request.Model, + ["context"] = context, + ["options"] = options, + }; + var bytes = SerializeBounded(payload, settings.MaxRequestBytes); + return new ProjectedMessageGatewayRequest(bytes, ResolveDebug(request, settings)); + } + + private static object ProjectMessage( + AgentMessage message, + MessageGatewaySettings settings, + string targetModel) => message.Role switch + { + AgentRole.Assistant => ProjectAssistant(message, settings, targetModel), + AgentRole.Tool => ProjectToolResult(message), + AgentRole.Custom => ProjectUser(message, "[role:" + SanitizeRole(message.CustomRole!) + "]"), + _ => ProjectUser(message, null), + }; + + private static object ProjectUser(AgentMessage message, string? prefix) + { + var content = ProjectTextAndImages(message.Content, allowImages: true).ToList(); + if (prefix is not null) + { + content.Insert(0, new Dictionary + { + ["type"] = "text", + ["text"] = prefix, + }); + } + + object wireContent = content.Count == 1 + && content[0] is Dictionary single + && single.Count == 2 + && string.Equals(single["type"] as string, "text", StringComparison.Ordinal) + ? single["text"]! + : content.ToArray(); + return new Dictionary + { + ["role"] = "user", + ["content"] = wireContent, + ["timestamp"] = message.Timestamp.ToUnixTimeMilliseconds(), + }; + } + + private static object ProjectAssistant( + AgentMessage message, + MessageGatewaySettings settings, + string targetModel) + { + var result = new Dictionary + { + ["role"] = "assistant", + ["content"] = message.Content.Select(ProjectAssistantContent).ToArray(), + ["api"] = message.Api ?? settings.ApiId, + ["provider"] = message.Provider ?? settings.ProviderId, + ["model"] = message.Model ?? targetModel, + ["usage"] = ProjectUsage(message.Usage ?? new ModelUsage()), + ["stopReason"] = StopReason(message.StopReason ?? ModelStopReason.Stop), + ["timestamp"] = message.Timestamp.ToUnixTimeMilliseconds(), + }; + Add(result, "responseModel", message.ResponseModel); + Add(result, "responseId", message.ResponseId); + Add(result, "errorMessage", message.ErrorMessage); + Add(result, "rawStopReason", message.RawStopReason); + if (message.EndTurn is { } endTurn) + { + result["endTurn"] = endTurn; + } + + if (message.Deferred is { } deferred) + { + result["deferred"] = ProjectDeferred(deferred); + } + + return result; + } + + private static object ProjectToolResult(AgentMessage message) + { + var result = new Dictionary + { + ["role"] = "toolResult", + ["toolCallId"] = message.ToolCallId, + ["toolName"] = message.ToolName, + ["content"] = ProjectTextAndImages(message.Content, allowImages: true).ToArray(), + ["isError"] = message.IsError, + ["timestamp"] = message.Timestamp.ToUnixTimeMilliseconds(), + }; + if (message.DetailsJson is not null) + { + result["details"] = ParseElement(message.DetailsJson); + } + + if (message.Usage is not null) + { + result["usage"] = ProjectUsage(message.Usage); + } + + if (message.AddedToolNames.Count > 0) + { + result["addedToolNames"] = message.AddedToolNames; + } + + return result; + } + + private static object ProjectAssistantContent(AgentContent content) => content switch + { + TextContent text => ProjectText(text), + ReasoningContent reasoning => ProjectReasoning(reasoning), + ToolCallContent call => ProjectToolCall(call), + JsonContent json => TextPart(json.Json), + BinaryContent binary => TextPart(UnsupportedAssistantPlaceholder(binary.MediaKind)), + ResourceContent => TextPart("[resource omitted: unsupported assistant content]"), + _ => TextPart("[content omitted: unsupported assistant content]"), + }; + + private static IEnumerable ProjectTextAndImages( + IEnumerable content, + bool allowImages) + { + foreach (var part in content) + { + switch (part) + { + case TextContent text: + yield return ProjectText(text); + break; + case JsonContent json: + yield return TextPart(json.Json); + break; + case BinaryContent binary when allowImages && binary.MediaKind == AgentMediaKind.Image: + yield return new Dictionary + { + ["type"] = "image", + ["data"] = binary.Data, + ["mimeType"] = binary.MediaType, + }; + break; + case BinaryContent binary: + yield return TextPart(UnsupportedInputPlaceholder(binary.MediaKind)); + break; + case ResourceContent: + yield return TextPart("[resource omitted: inline data required]"); + break; + default: + yield return TextPart("[content omitted: unsupported message content]"); + break; + } + } + } + + private static object ProjectText(TextContent text) + { + var result = TextPart(text.Text); + Add(result, "textSignature", text.Signature); + return result; + } + + private static object ProjectReasoning(ReasoningContent reasoning) + { + var result = new Dictionary + { + ["type"] = "thinking", + ["thinking"] = reasoning.Text, + }; + Add(result, "thinkingSignature", reasoning.Signature); + if (reasoning.Redacted) + { + result["redacted"] = true; + } + + return result; + } + + private static object ProjectToolCall(ToolCallContent call) + { + var result = new Dictionary + { + ["type"] = "toolCall", + ["id"] = call.Id, + ["name"] = call.Name, + ["arguments"] = ParseElement(call.ArgumentsJson), + }; + Add(result, "thoughtSignature", call.ThoughtSignature); + Add(result, "namespace", call.Namespace); + return result; + } + + private static object ProjectTool(ToolDefinition tool) + { + var result = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = ParseElement(tool.InputSchemaJson), + }; + if (tool.ConstrainedSampling is { } constrained) + { + result["constrainedSampling"] = constrained.Kind switch + { + ToolConstrainedSamplingKind.JsonSchema => new Dictionary + { + ["type"] = "json_schema", + ["strict"] = constrained.Strictness == ToolSchemaStrictness.Require ? "require" : "prefer", + }, + ToolConstrainedSamplingKind.Grammar => new Dictionary + { + ["type"] = "grammar", + ["variants"] = GrammarVariants(constrained), + }, + _ => throw new InvalidOperationException("The tool uses an unsupported constrained-sampling mode."), + }; + } + + return result; + } + + private static object GrammarVariants(ToolConstrainedSampling constrained) + { + var variants = new Dictionary(); + if (constrained.OpenAiLark is not null) + { + variants["openai_lark"] = constrained.OpenAiLark; + } + + if (constrained.OpenAiRegex is not null) + { + variants["openai_regex"] = constrained.OpenAiRegex; + } + + return variants; + } + + private static Dictionary ProjectOptions( + ModelRequest request, + MessageGatewaySettings settings) + { + var result = new Dictionary(); + if (request.Parameters.Temperature is { } temperature) + { + if (double.IsNaN(temperature) || double.IsInfinity(temperature) || temperature is < 0 or > 2) + { + throw new ArgumentOutOfRangeException(nameof(request), "Temperature must be between zero and two."); + } + + result["temperature"] = temperature; + } + + if (request.Parameters.MaxOutputTokens is { } maxTokens) + { + if (maxTokens < 1) + { + throw new ArgumentOutOfRangeException(nameof(request), "Maximum output tokens must be positive."); + } + + result["maxTokens"] = maxTokens; + } + + if (request.Parameters.ReasoningLevel is { } reasoning) + { + var normalized = reasoning.ToLowerInvariant(); + if (normalized is not ("minimal" or "low" or "medium" or "high" or "xhigh" or "max")) + { + throw new InvalidOperationException("The message gateway reasoning level is invalid."); + } + + result["reasoning"] = normalized; + } + + result["cacheRetention"] = request.Parameters.CacheRetention switch + { + ModelCacheRetention.None => "none", + ModelCacheRetention.Short => "short", + ModelCacheRetention.Long => "long", + _ => throw new InvalidOperationException("The message gateway cache-retention value is invalid."), + }; + Add(result, "sessionId", request.SessionId); + var toolChoice = ResolveToolChoice(request, settings); + if (toolChoice is not null) + { + result["toolChoice"] = toolChoice; + } + + return result; + } + + private static object? ResolveToolChoice(ModelRequest request, MessageGatewaySettings settings) + { + if (request.Parameters.Extensions.TryGetValue(MessageGatewayParameterKeys.ToolChoice, out var requested)) + { + return ParseToolChoice(requested, request.Tools); + } + + return settings.ToolChoice switch + { + null => null, + MessageGatewayToolChoiceMode.Auto => "auto", + MessageGatewayToolChoiceMode.None => "none", + MessageGatewayToolChoiceMode.Required => "required", + MessageGatewayToolChoiceMode.Function => FunctionToolChoice( + MessageGatewaySettings.RequireToolName(settings.ToolName, nameof(settings.ToolName)), + request.Tools), + _ => throw new InvalidOperationException("The message gateway tool choice is invalid."), + }; + } + + private static object ParseToolChoice(string value, IReadOnlyList tools) + { + if (value is "auto" or "none" or "required") + { + return value; + } + + const string prefix = "function:"; + if (value.StartsWith(prefix, StringComparison.Ordinal)) + { + return FunctionToolChoice( + MessageGatewaySettings.RequireToolName(value.Substring(prefix.Length), nameof(value)), + tools); + } + + throw new InvalidOperationException("The message gateway tool-choice extension is invalid."); + } + + private static object FunctionToolChoice(string name, IReadOnlyList tools) + { + if (!tools.Any(tool => string.Equals(tool.Name, name, StringComparison.Ordinal))) + { + throw new InvalidOperationException("The selected message gateway tool is not present in the request."); + } + + return new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary { ["name"] = name }, + }; + } + + private static bool ResolveDebug(ModelRequest request, MessageGatewaySettings settings) + { + if (!request.Parameters.Extensions.TryGetValue(MessageGatewayParameterKeys.Debug, out var value)) + { + return settings.Debug; + } + + return bool.TryParse(value, out var result) + ? result + : throw new InvalidOperationException("The message gateway debug extension must be true or false."); + } + + private static object ProjectUsage(ModelUsage usage) + { + var result = new Dictionary + { + ["input"] = usage.InputTokens, + ["output"] = usage.OutputTokens, + ["cacheRead"] = usage.CacheReadTokens, + ["cacheWrite"] = usage.CacheWriteTokens, + ["totalTokens"] = usage.TotalTokens, + ["cost"] = new Dictionary + { + ["input"] = usage.Cost.Input, + ["output"] = usage.Cost.Output, + ["cacheRead"] = usage.Cost.CacheRead, + ["cacheWrite"] = usage.Cost.CacheWrite, + ["total"] = usage.Cost.Total, + }, + }; + if (usage.CacheWriteOneHourTokens is { } cacheWriteOneHour) + { + result["cacheWrite1h"] = cacheWriteOneHour; + } + + if (usage.ReasoningTokens is { } reasoning) + { + result["reasoning"] = reasoning; + } + + return result; + } + + private static object ProjectDeferred(DeferredModelHandle deferred) + { + var result = new Dictionary + { + ["provider"] = deferred.Provider, + ["modelId"] = deferred.Model, + ["api"] = deferred.Api, + ["id"] = deferred.Id, + }; + if (deferred.ExpiresAt is { } expiresAt) + { + result["expiresAt"] = expiresAt.ToUnixTimeMilliseconds(); + } + + if (deferred.PollAfterMilliseconds is { } pollAfter) + { + result["pollAfterMs"] = pollAfter; + } + + if (deferred.DataJson is not null) + { + result["data"] = ParseElement(deferred.DataJson); + } + + return result; + } + + private static Dictionary TextPart(string value) => new() + { + ["type"] = "text", + ["text"] = value, + }; + + private static string UnsupportedInputPlaceholder(AgentMediaKind kind) => + "[" + kind.ToString().ToLowerInvariant() + " omitted: message gateway supports only text and images]"; + + private static string UnsupportedAssistantPlaceholder(AgentMediaKind kind) => + "[" + kind.ToString().ToLowerInvariant() + " omitted: unsupported assistant content]"; + + private static string SanitizeRole(string role) + { + var builder = new StringBuilder(Math.Min(role.Length, 128)); + foreach (var character in role) + { + if (builder.Length >= 128) + { + break; + } + + builder.Append(char.IsControl(character) ? ' ' : character); + } + + return builder.ToString(); + } + + private static string StopReason(ModelStopReason reason) => reason switch + { + ModelStopReason.Pending => "pending", + ModelStopReason.Stop => "stop", + ModelStopReason.ToolUse => "toolUse", + ModelStopReason.Length => "length", + ModelStopReason.Error => "error", + ModelStopReason.Aborted => "aborted", + ModelStopReason.Deferred => "deferred", + _ => throw new InvalidOperationException("The message stop reason is invalid."), + }; + + private static JsonElement ParseElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static void Add(IDictionary target, string key, string? value) + { + if (value is not null) + { + target[key] = value; + } + } + + private static byte[] SerializeBounded(object payload, int maximumBytes) + { + using var memory = new MemoryStream(); + using var bounded = new BoundedWriteStream(memory, maximumBytes); + using (var writer = new Utf8JsonWriter(bounded)) + { + JsonSerializer.Serialize(writer, payload, JsonOptions); + writer.Flush(); + } + + return memory.ToArray(); + } + + private sealed class BoundedWriteStream : Stream + { + private readonly Stream _inner; + private readonly long _maximumBytes; + private long _written; + + public BoundedWriteStream(Stream inner, long maximumBytes) + { + _inner = inner; + _maximumBytes = maximumBytes; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => _written; + public override long Position + { + get => _written; + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + EnsureCapacity(count); + _inner.Write(buffer, offset, count); + _written += count; + } + + public override async Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + EnsureCapacity(count); + await _inner.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + _written += count; + } + + private void EnsureCapacity(int count) + { + if (_written + count > _maximumBytes) + { + throw new InvalidDataException("The message gateway request exceeded its size limit."); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Flush(); + } + + base.Dispose(disposing); + } + } +} + +internal static class MessageGatewayJson +{ + public static JsonDocument Parse(string json, int maximumDepth) + { + var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + MaxDepth = maximumDepth, + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }); + try + { + EnsureUnambiguous(document.RootElement); + return document; + } + catch + { + document.Dispose(); + throw; + } + } + + public static string RequiredString(JsonElement value, string property, int maximumCharacters) + { + if (!value.TryGetProperty(property, out var element) + || element.ValueKind != JsonValueKind.String + || element.GetString() is not { } result + || string.IsNullOrWhiteSpace(result) + || result.Length > maximumCharacters + || result.Any(char.IsControl)) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return result; + } + + public static string? OptionalString(JsonElement value, string property, int maximumCharacters) + { + if (!value.TryGetProperty(property, out var element)) + { + return null; + } + + if (element.ValueKind != JsonValueKind.String + || element.GetString() is not { } result + || result.Length > maximumCharacters + || result.IndexOf('\0') >= 0) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return result; + } + + public static int RequiredIndex(JsonElement value, string property, int maximumExclusive) + { + if (!value.TryGetProperty(property, out var element) + || !element.TryGetInt32(out var result) + || result < 0 + || result >= maximumExclusive) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return result; + } + + public static long RequiredInt64(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var element) || !element.TryGetInt64(out var result)) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return result; + } + + public static bool RequiredBoolean(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var element) + || element.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return element.GetBoolean(); + } + + public static bool? OptionalBoolean(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var element)) + { + return null; + } + + if (element.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return element.GetBoolean(); + } + + public static double RequiredDouble(JsonElement value, string property) + { + if (!value.TryGetProperty(property, out var element) + || !element.TryGetDouble(out var result) + || double.IsNaN(result) + || double.IsInfinity(result) + || result < 0) + { + throw new InvalidDataException($"The message gateway event field '{property}' is invalid."); + } + + return result; + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("A message gateway JSON object contains duplicate property names."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } +} + +internal sealed class MessageGatewayStreamState +{ + private readonly ModelRequest _request; + private readonly MessageGatewaySettings _settings; + private readonly MessageGatewaySecretRedactor _redactor; + private readonly List _content = new(); + private readonly Dictionary _text = new(); + private readonly Dictionary _reasoning = new(); + private readonly Dictionary _toolArguments = new(); + private bool _started; + private bool _terminal; + private int _contentCharacters; + private int _toolCalls; + private long _partialSnapshotWork; + + public MessageGatewayStreamState( + ModelRequest request, + MessageGatewaySettings settings, + MessageGatewaySecretRedactor redactor) + { + _request = request; + _settings = settings; + _redactor = redactor; + } + + public ModelStreamEvent Apply(string json) + { + using var document = MessageGatewayJson.Parse(json, _settings.MaxJsonDepth); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("A message gateway event must be a JSON object."); + } + + var type = MessageGatewayJson.RequiredString(root, "type", 64); + if (_terminal) + { + throw new InvalidDataException("The message gateway emitted an event after its terminal event."); + } + + return type switch + { + "start" => Start(), + "text_start" => TextStart(root), + "text_delta" => TextDelta(root), + "text_end" => TextEnd(root), + "thinking_start" => ReasoningStart(root), + "thinking_delta" => ReasoningDelta(root), + "thinking_end" => ReasoningEnd(root), + "toolcall_start" => ToolStart(root), + "toolcall_delta" => ToolDelta(root), + "toolcall_end" => ToolEnd(root), + "done" => Terminal(root, isError: false), + "error" => Terminal(root, isError: true), + _ => throw new InvalidDataException( + $"Unknown message gateway event type '{_redactor.Sanitize(type, 64)}'."), + }; + } + + public void EnsureComplete() + { + if (!_terminal) + { + throw new InvalidDataException("The message gateway stream ended without a terminal event."); + } + } + + private ModelStreamEvent Start() + { + if (_started) + { + throw new InvalidDataException("The message gateway emitted more than one start event."); + } + + _started = true; + return ModelStreamEvent.Update(ModelStreamEventKind.Started, Partial()); + } + + private ModelStreamEvent TextStart(JsonElement root) + { + RequireStarted(); + var index = AppendIndex(root); + _text.Add(index, new StringBuilder()); + _content.Add(new TextContent(string.Empty)); + return ModelStreamEvent.Update(ModelStreamEventKind.TextStarted, Partial(), contentIndex: index); + } + + private ModelStreamEvent TextDelta(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_text.TryGetValue(index, out var buffer)) + { + throw new InvalidDataException("A message gateway text delta has no active text block."); + } + + var delta = MessageGatewayJson.OptionalString(root, "delta", _settings.MaxContentCharacters) + ?? throw new InvalidDataException("A message gateway text delta is missing content."); + AppendContent(buffer, delta); + AddPartialSnapshotWork(buffer.Length); + _content[index] = new TextContent(buffer.ToString()); + return ModelStreamEvent.Update( + ModelStreamEventKind.TextDelta, + Partial(), + delta, + index); + } + + private ModelStreamEvent TextEnd(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_text.Remove(index, out var buffer)) + { + throw new InvalidDataException("A message gateway text end has no active text block."); + } + + var content = MessageGatewayJson.OptionalString(root, "content", _settings.MaxContentCharacters) + ?? throw new InvalidDataException("A message gateway text end is missing content."); + ReconcileFinalContent(buffer, content); + var signature = MessageGatewayJson.OptionalString(root, "contentSignature", 1_000_000); + _content[index] = new TextContent(content, signature); + return ModelStreamEvent.Update( + ModelStreamEventKind.TextEnded, + Partial(), + contentIndex: index, + content: content); + } + + private ModelStreamEvent ReasoningStart(JsonElement root) + { + RequireStarted(); + var index = AppendIndex(root); + _reasoning.Add(index, new StringBuilder()); + _content.Add(new ReasoningContent(string.Empty)); + return ModelStreamEvent.Update(ModelStreamEventKind.ReasoningStarted, Partial(), contentIndex: index); + } + + private ModelStreamEvent ReasoningDelta(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_reasoning.TryGetValue(index, out var buffer)) + { + throw new InvalidDataException("A message gateway reasoning delta has no active reasoning block."); + } + + var delta = MessageGatewayJson.OptionalString(root, "delta", _settings.MaxContentCharacters) + ?? throw new InvalidDataException("A message gateway reasoning delta is missing content."); + AppendContent(buffer, delta); + AddPartialSnapshotWork(buffer.Length); + _content[index] = new ReasoningContent(buffer.ToString()); + return ModelStreamEvent.Update( + ModelStreamEventKind.ReasoningDelta, + Partial(), + delta, + index); + } + + private ModelStreamEvent ReasoningEnd(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_reasoning.Remove(index, out var buffer)) + { + throw new InvalidDataException("A message gateway reasoning end has no active reasoning block."); + } + + var content = MessageGatewayJson.OptionalString(root, "content", _settings.MaxContentCharacters) + ?? throw new InvalidDataException("A message gateway reasoning end is missing content."); + ReconcileFinalContent(buffer, content); + var signature = MessageGatewayJson.OptionalString(root, "contentSignature", 1_000_000); + var redacted = MessageGatewayJson.OptionalBoolean(root, "redacted") ?? false; + _content[index] = new ReasoningContent(content, signature, redacted); + return ModelStreamEvent.Update( + ModelStreamEventKind.ReasoningEnded, + Partial(), + contentIndex: index, + content: content); + } + + private ModelStreamEvent ToolStart(JsonElement root) + { + RequireStarted(); + if (++_toolCalls > _settings.MaxToolCalls) + { + throw new InvalidDataException("The message gateway exceeded its tool-call limit."); + } + + var index = AppendIndex(root); + var id = MessageGatewayJson.RequiredString(root, "id", 1_024); + var name = MessageGatewayJson.RequiredString(root, "toolName", 256); + _toolArguments.Add(index, new StringBuilder()); + _content.Add(new ToolCallContent(id, name, "{}")); + return ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallStarted, + Partial(), + contentIndex: index, + toolCallId: id, + toolName: name); + } + + private ModelStreamEvent ToolDelta(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_toolArguments.TryGetValue(index, out var buffer) + || _content[index] is not ToolCallContent current) + { + throw new InvalidDataException("A message gateway tool delta has no active tool call."); + } + + var delta = MessageGatewayJson.OptionalString(root, "delta", _settings.MaxContentCharacters) + ?? throw new InvalidDataException("A message gateway tool delta is missing content."); + AppendContent(buffer, delta); + AddPartialSnapshotWork(buffer.Length); + if (TryCanonicalObject(buffer.ToString(), out var arguments)) + { + _content[index] = new ToolCallContent(current.Id, current.Name, arguments); + } + + return ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + delta, + index, + current.Id, + current.Name); + } + + private ModelStreamEvent ToolEnd(JsonElement root) + { + RequireStarted(); + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (!_toolArguments.Remove(index, out var buffer) + || _content[index] is not ToolCallContent current) + { + throw new InvalidDataException("A message gateway tool end has no active tool call."); + } + + if (!root.TryGetProperty("toolCall", out var toolCall) || toolCall.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("A message gateway tool end is missing its tool call."); + } + + var id = MessageGatewayJson.RequiredString(toolCall, "id", 1_024); + var name = MessageGatewayJson.RequiredString(toolCall, "name", 256); + if (!string.Equals(id, current.Id, StringComparison.Ordinal) + || !string.Equals(name, current.Name, StringComparison.Ordinal)) + { + throw new InvalidDataException("A message gateway tool end does not match its start event."); + } + + if (!toolCall.TryGetProperty("arguments", out var argumentElement) + || argumentElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("A message gateway tool call requires object arguments."); + } + + var arguments = JsonSerializer.Serialize(argumentElement); + if (buffer.Length > 0) + { + AddPartialSnapshotWork(buffer.Length); + if (!JsonObjectsEquivalent(buffer.ToString(), argumentElement)) + { + throw new InvalidDataException("A message gateway tool-call stream does not match its final arguments."); + } + } + else + { + AddContentCharacters(arguments.Length); + } + + var thoughtSignature = MessageGatewayJson.OptionalString(toolCall, "thoughtSignature", 1_000_000); + var toolNamespace = MessageGatewayJson.OptionalString(toolCall, "namespace", 256); + if (toolNamespace?.Any(char.IsControl) == true) + { + throw new InvalidDataException("A message gateway tool namespace is invalid."); + } + + var completed = new ToolCallContent(id, name, arguments, thoughtSignature, toolNamespace); + _content[index] = completed; + return ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + Partial(), + contentIndex: index, + toolCallId: id, + toolName: name, + toolCall: completed); + } + + private ModelStreamEvent Terminal(JsonElement root, bool isError) + { + if (_text.Count > 0 || _reasoning.Count > 0 || _toolArguments.Count > 0) + { + throw new InvalidDataException("The message gateway terminated with unfinished content blocks."); + } + + var rawReason = MessageGatewayJson.RequiredString(root, "reason", 64); + var stopReason = rawReason switch + { + "stop" when !isError => ModelStopReason.Stop, + "length" when !isError => ModelStopReason.Length, + "toolUse" when !isError => ModelStopReason.ToolUse, + "error" when isError => ModelStopReason.Error, + "aborted" when isError => ModelStopReason.Aborted, + _ => throw new InvalidDataException("The message gateway terminal reason is invalid."), + }; + if (!root.TryGetProperty("usage", out var usageElement) || usageElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("The message gateway terminal event is missing usage."); + } + + var usage = ParseUsage(usageElement); + var responseId = MessageGatewayJson.OptionalString(root, "responseId", 1_024); + if (responseId is not null) + { + responseId = _redactor.Sanitize(responseId, 1_024); + } + + var diagnostics = ParseRewrite(root); + var errorMessage = isError + ? MessageGatewayJson.OptionalString(root, "errorMessage", _settings.MaxErrorCharacters) + ?? (stopReason == ModelStopReason.Aborted + ? "The message gateway request was aborted." + : "The message gateway reported an error.") + : null; + if (errorMessage is not null) + { + errorMessage = _redactor.Sanitize(errorMessage, _settings.MaxErrorCharacters); + } + + if (stopReason == ModelStopReason.Error && !HasMeaningfulOutput(usage)) + { + throw CreateRetryableStreamFailure(errorMessage!, diagnostics); + } + + _terminal = true; + return ModelStreamEvent.Terminal(new ModelResponse( + _content, + stopReason, + usage, + errorMessage, + _settings.ProviderId, + _settings.ApiId, + _request.Model, + responseId, + rawReason, + diagnostics: diagnostics)); + } + + private ModelUsage ParseUsage(JsonElement usage) + { + var input = MessageGatewayJson.RequiredInt64(usage, "input"); + var output = MessageGatewayJson.RequiredInt64(usage, "output"); + var cacheRead = MessageGatewayJson.RequiredInt64(usage, "cacheRead"); + var cacheWrite = MessageGatewayJson.RequiredInt64(usage, "cacheWrite"); + var total = MessageGatewayJson.RequiredInt64(usage, "totalTokens"); + if (input < 0 + || output < 0 + || cacheRead < 0 + || cacheWrite < 0 + || total != checked(input + output + cacheRead + cacheWrite)) + { + throw new InvalidDataException("The message gateway usage totals are invalid."); + } + + long? reasoning = null; + if (usage.TryGetProperty("reasoning", out var reasoningElement)) + { + if (!reasoningElement.TryGetInt64(out var value)) + { + throw new InvalidDataException("The message gateway reasoning usage is invalid."); + } + + reasoning = value; + } + + long? oneHour = null; + if (usage.TryGetProperty("cacheWrite1h", out var oneHourElement)) + { + if (!oneHourElement.TryGetInt64(out var value)) + { + throw new InvalidDataException("The message gateway cache-write usage is invalid."); + } + + oneHour = value; + } + + if (!usage.TryGetProperty("cost", out var cost) || cost.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("The message gateway usage is missing cost data."); + } + + var modelCost = new ModelCost( + MessageGatewayJson.RequiredDouble(cost, "input"), + MessageGatewayJson.RequiredDouble(cost, "output"), + MessageGatewayJson.RequiredDouble(cost, "cacheRead"), + MessageGatewayJson.RequiredDouble(cost, "cacheWrite")); + var reportedCostTotal = MessageGatewayJson.RequiredDouble(cost, "total"); + var computedCostTotal = modelCost.Input + modelCost.Output + modelCost.CacheRead + modelCost.CacheWrite; + if (double.IsNaN(computedCostTotal) || double.IsInfinity(computedCostTotal)) + { + throw new InvalidDataException("The message gateway cost total is outside supported bounds."); + } + + var costTolerance = Math.Max(1e-12, Math.Abs(computedCostTotal) * 1e-9); + if (Math.Abs(reportedCostTotal - computedCostTotal) > costTolerance) + { + throw new InvalidDataException("The message gateway cost totals are invalid."); + } + + try + { + return new ModelUsage(input, output, cacheRead, cacheWrite, reasoning, oneHour, modelCost); + } + catch (ArgumentOutOfRangeException exception) + { + throw new InvalidDataException("The message gateway usage is outside supported bounds.", exception); + } + } + + private static IReadOnlyList ParseRewrite(JsonElement root) + { + if (!root.TryGetProperty("rewrite", out var rewrite)) + { + return Array.Empty(); + } + + if (rewrite.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("The message gateway rewrite summary is invalid."); + } + + var policyId = MessageGatewayJson.RequiredString(rewrite, "policyId", 256); + var policyVersion = MessageGatewayJson.RequiredInt64(rewrite, "policyVersion"); + var changed = MessageGatewayJson.RequiredBoolean(rewrite, "changed"); + var tokenCountChange = MessageGatewayJson.RequiredInt64(rewrite, "tokenCountChange"); + var messageCountChange = MessageGatewayJson.RequiredInt64(rewrite, "messageCountChange"); + var systemPromptChanged = MessageGatewayJson.RequiredBoolean(rewrite, "systemPromptChanged"); + if (policyVersion < 0 + || tokenCountChange is < -10_000_000_000 or > 10_000_000_000 + || messageCountChange is < -1_000_000 or > 1_000_000) + { + throw new InvalidDataException("The message gateway rewrite summary is outside supported bounds."); + } + + var data = JsonSerializer.Serialize(new + { + policyId, + policyVersion, + changed, + tokenCountChange, + messageCountChange, + systemPromptChanged, + }); + return Array.AsReadOnly(new[] + { + new ModelDiagnostic( + "message_gateway_rewrite", + changed + ? "The message gateway rewrote the request context." + : "The message gateway evaluated the request context without changes.", + ModelDiagnosticSeverity.Information, + data), + }); + } + + private int AppendIndex(JsonElement root) + { + var index = MessageGatewayJson.RequiredIndex(root, "contentIndex", _settings.MaxContentBlocks); + if (index != _content.Count || _content.Count >= _settings.MaxContentBlocks) + { + throw new InvalidDataException("Message gateway content indices must be contiguous and unique."); + } + + return index; + } + + private void RequireStarted() + { + if (!_started) + { + throw new InvalidDataException("The message gateway emitted content before its start event."); + } + } + + private void AppendContent(StringBuilder buffer, string value) + { + AddContentCharacters(value.Length); + buffer.Append(value); + } + + private void ReconcileFinalContent(StringBuilder streamed, string final) + { + if (streamed.Length > 0) + { + AddPartialSnapshotWork(streamed.Length); + if (!string.Equals(streamed.ToString(), final, StringComparison.Ordinal)) + { + throw new InvalidDataException("Message gateway streamed content does not match its final value."); + } + } + + if (streamed.Length == 0) + { + AddContentCharacters(final.Length); + } + } + + private void AddContentCharacters(int count) + { + _contentCharacters = checked(_contentCharacters + count); + if (_contentCharacters > _settings.MaxContentCharacters) + { + throw new InvalidDataException("The message gateway exceeded its content-character limit."); + } + } + + private bool TryCanonicalObject(string json, out string canonical) + { + try + { + using var document = MessageGatewayJson.Parse(json, _settings.MaxJsonDepth); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + canonical = string.Empty; + return false; + } + + canonical = JsonSerializer.Serialize(document.RootElement); + return true; + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + canonical = string.Empty; + return false; + } + } + + private bool JsonObjectsEquivalent(string json, JsonElement expected) + { + try + { + using var document = MessageGatewayJson.Parse(json, _settings.MaxJsonDepth); + return document.RootElement.ValueKind == JsonValueKind.Object + && JsonEquivalent(document.RootElement, expected); + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + return false; + } + } + + private static bool JsonEquivalent(JsonElement first, JsonElement second) + { + if (first.ValueKind != second.ValueKind) + { + if (first.ValueKind == JsonValueKind.Number && second.ValueKind == JsonValueKind.Number) + { + return NumbersEquivalent(first, second); + } + + return false; + } + + switch (first.ValueKind) + { + case JsonValueKind.Object: + { + var firstProperties = first.EnumerateObject().ToArray(); + var secondProperties = second.EnumerateObject().ToArray(); + if (firstProperties.Length != secondProperties.Length) + { + return false; + } + + foreach (var property in firstProperties) + { + if (!second.TryGetProperty(property.Name, out var value) + || !JsonEquivalent(property.Value, value)) + { + return false; + } + } + + return true; + } + case JsonValueKind.Array: + { + var firstItems = first.EnumerateArray().ToArray(); + var secondItems = second.EnumerateArray().ToArray(); + return firstItems.Length == secondItems.Length + && firstItems.Zip(secondItems, JsonEquivalent).All(equal => equal); + } + case JsonValueKind.String: + return string.Equals(first.GetString(), second.GetString(), StringComparison.Ordinal); + case JsonValueKind.Number: + return NumbersEquivalent(first, second); + case JsonValueKind.True: + case JsonValueKind.False: + return first.GetBoolean() == second.GetBoolean(); + case JsonValueKind.Null: + return true; + default: + return string.Equals(first.GetRawText(), second.GetRawText(), StringComparison.Ordinal); + } + } + + private static bool NumbersEquivalent(JsonElement first, JsonElement second) + { + if (first.TryGetDecimal(out var firstDecimal) && second.TryGetDecimal(out var secondDecimal)) + { + return firstDecimal == secondDecimal; + } + + return first.TryGetDouble(out var firstDouble) + && second.TryGetDouble(out var secondDouble) + && !double.IsNaN(firstDouble) + && !double.IsInfinity(firstDouble) + && !double.IsNaN(secondDouble) + && !double.IsInfinity(secondDouble) + && firstDouble.Equals(secondDouble); + } + + private bool HasMeaningfulOutput(ModelUsage usage) => + _content.Any(content => content switch + { + TextContent text => text.Text.Length > 0, + ReasoningContent reasoning => reasoning.Text.Length > 0, + ToolCallContent => true, + _ => false, + }) + || usage.TotalTokens > 0 + || usage.Cost.Total > 0; + + private ModelProviderException CreateRetryableStreamFailure( + string errorMessage, + IReadOnlyList diagnostics) + { + var combined = diagnostics.Concat(new[] + { + new ModelDiagnostic( + "message_gateway_stream_failure", + "The message gateway failed before producing meaningful output.", + ModelDiagnosticSeverity.Error), + }); + return new ModelProviderException( + errorMessage, + combined, + isTransient: true); + } + + private void AddPartialSnapshotWork(long units) + { + _partialSnapshotWork = checked(_partialSnapshotWork + units); + if (_partialSnapshotWork > _settings.MaxPartialSnapshotWork) + { + throw new InvalidDataException("The message gateway exceeded its partial-snapshot work limit."); + } + } + + private ModelResponse Partial() + { + AddPartialSnapshotWork(_content.Count); + return new ModelResponse( + _content, + ModelStopReason.Pending, + provider: _settings.ProviderId, + api: _settings.ApiId, + responseModel: _request.Model); + } +} diff --git a/src/OpenGameAgent.Providers.MessageGateway/OpenGameAgent.Providers.MessageGateway.csproj b/src/OpenGameAgent.Providers.MessageGateway/OpenGameAgent.Providers.MessageGateway.csproj new file mode 100644 index 0000000..a49ce6b --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/OpenGameAgent.Providers.MessageGateway.csproj @@ -0,0 +1,10 @@ + + + netstandard2.1 + Bounded message-gateway streaming provider for OpenGameAgent. + + + + + + diff --git a/src/OpenGameAgent.Providers.MessageGateway/packages.lock.json b/src/OpenGameAgent.Providers.MessageGateway/packages.lock.json new file mode 100644 index 0000000..9268a26 --- /dev/null +++ b/src/OpenGameAgent.Providers.MessageGateway/packages.lock.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.Mistral/MistralConversationsProvider.cs b/src/OpenGameAgent.Providers.Mistral/MistralConversationsProvider.cs new file mode 100644 index 0000000..d108a3c --- /dev/null +++ b/src/OpenGameAgent.Providers.Mistral/MistralConversationsProvider.cs @@ -0,0 +1,741 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.Mistral; + +public delegate ValueTask MistralApiKeyProvider(CancellationToken cancellationToken); + +public enum MistralToolChoice +{ + Auto, + None, + Any, + Required, + Function, +} + +public enum MistralReasoningMode +{ + Auto, + PromptMode, + Effort, +} + +public sealed class MistralConversationsProviderOptions +{ + public MistralConversationsProviderOptions(HttpClient httpClient, Uri endpoint) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public string? ApiKey { get; set; } + + public MistralApiKeyProvider? GetApiKeyAsync { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public string ProviderId { get; set; } = "mistral"; + + public string ApiId { get; set; } = "mistral-conversations"; + + public bool SupportsImages { get; set; } = true; + + public MistralToolChoice? ToolChoice { get; set; } + + public string? RequiredToolName { get; set; } + + public MistralReasoningMode ReasoningMode { get; set; } = MistralReasoningMode.Auto; + + public bool AllowInsecureHttp { get; set; } + + public int MaxEventCharacters { get; set; } = 4_000_000; + + public int MaxErrorCharacters { get; set; } = 4_000; + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseCharacters { get; set; } = 16_000_000; + + public int MaxToolCallsPerResponse { get; set; } = 256; +} + +public sealed class MistralConversationsProvider : IModelProvider, IModelProviderCapabilities +{ + private readonly MistralConversationsProviderOptions _options; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; + private readonly IReadOnlyCollection _supportedApis; + + public MistralConversationsProvider(MistralConversationsProviderOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + ValidateOptions(options); + _headers = new ReadOnlyDictionary( + new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase)); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + _supportedApis = Array.AsReadOnly(new[] { options.ApiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => false; + + public bool SupportsDeferredResponses => false; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("This provider currently uses the Mistral server-sent-event transport."); + } + + var apiKey = _options.GetApiKeyAsync is null + ? _options.ApiKey + : await ProviderCallbackRunner.RunAsync( + token => _options.GetApiKeyAsync(token), + cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("A Mistral API key is required."); + } + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint); + ApplyHeaders(httpRequest, apiKey!, request); + httpRequest.Content = new ByteArrayContent(SerializeRequest(request)); + httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; + using var response = await _options.HttpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromHttpResponse( + _options.ProviderId, + _options.ApiId, + request.Model, + response), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var error = await ReadBoundedAsync(response.Content, _options.MaxErrorCharacters, cancellationToken) + .ConfigureAwait(false); + var retry = ProviderHttpRetryMetadata.FromResponse(response, errorText: error); + throw new ModelProviderException( + $"The Mistral endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", + retry.IsTransient, + retry.RetryAfter, + (int)response.StatusCode); + } + + using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(responseStream.Dispose); + using var reader = new StreamReader(responseStream, Encoding.UTF8, true, 4096, leaveOpen: false); + var state = new MistralStreamState( + request.Model, + _options.ProviderId, + _options.ApiId, + _options.MaxResponseCharacters, + _options.MaxToolCallsPerResponse); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + await foreach (var data in ReadSseDataAsync(reader, _options.MaxEventCharacters, cancellationToken)) + { + if (data.Length == 0 || string.Equals(data, "[DONE]", StringComparison.Ordinal)) + { + continue; + } + + foreach (var update in state.Apply(data)) + { + yield return update; + } + } + + foreach (var update in state.CloseOpenBlocks()) + { + yield return update; + } + + yield return ModelStreamEvent.Terminal(state.Complete()); + } + + private byte[] SerializeRequest(ModelRequest request) + { + var normalizer = MistralToolCallIds.CreateNormalizer(); + var messages = ProviderTranscript.Normalize( + request.Messages, + _options.ProviderId, + _options.ApiId, + request.Model, + normalizer); + var payload = new Dictionary + { + ["model"] = request.Model, + ["stream"] = true, + ["messages"] = ProjectMessages(messages, request.SystemPrompt), + }; + if (request.Tools.Count > 0) + { + payload["tools"] = ProjectTools(request.Tools); + } + + if (request.Parameters.Temperature is { } temperature) + { + payload["temperature"] = temperature; + } + + if (request.Parameters.MaxOutputTokens is { } maxTokens) + { + payload["max_tokens"] = maxTokens; + } + + ApplyToolChoice(payload); + ApplyReasoning(payload, request.Model, request.Parameters.ReasoningLevel); + if (request.Parameters.CacheRetention != ModelCacheRetention.None + && !string.IsNullOrWhiteSpace(request.SessionId)) + { + payload["prompt_cache_key"] = request.SessionId; + } + + MergeSampling(payload, request.Parameters.SamplingParametersJson); + foreach (var extension in request.Parameters.Extensions) + { + if (payload.ContainsKey(extension.Key)) + { + throw new InvalidOperationException($"Model extension '{extension.Key}' cannot override a core request field."); + } + + payload[extension.Key] = ParseJsonOrString(extension.Value); + } + + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload); + if (bytes.Length > _options.MaxRequestBytes) + { + throw new InvalidOperationException("The Mistral request exceeded the configured byte limit."); + } + + return bytes; + } + + private IReadOnlyList ProjectMessages(IReadOnlyList messages, string systemPrompt) + { + var result = new List(); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + result.Add(new Dictionary + { + ["role"] = "system", + ["content"] = SanitizeUnicode(systemPrompt), + }); + } + + foreach (var message in messages) + { + if (message.Role is AgentRole.User or AgentRole.Custom) + { + var content = ProjectUserContent(message.Content); + if (content.Count > 0) + { + result.Add(new Dictionary { ["role"] = "user", ["content"] = content }); + } + + continue; + } + + if (message.Role == AgentRole.Assistant) + { + var content = new List(); + var calls = new List(); + foreach (var item in message.Content) + { + if (item is TextContent text && !string.IsNullOrWhiteSpace(text.Text)) + { + content.Add(new Dictionary { ["type"] = "text", ["text"] = SanitizeUnicode(text.Text) }); + } + else if (item is ReasoningContent reasoning && !string.IsNullOrWhiteSpace(reasoning.Text)) + { + content.Add(new Dictionary + { + ["type"] = "thinking", + ["thinking"] = new object[] + { + new Dictionary { ["type"] = "text", ["text"] = SanitizeUnicode(reasoning.Text) }, + }, + }); + } + else if (item is ToolCallContent call) + { + calls.Add(new Dictionary + { + ["id"] = call.Id, + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = call.Name, + ["arguments"] = call.ArgumentsJson, + }, + }); + } + } + + if (content.Count > 0 || calls.Count > 0) + { + var projected = new Dictionary { ["role"] = "assistant" }; + if (content.Count > 0) + { + projected["content"] = content; + } + + if (calls.Count > 0) + { + projected["tool_calls"] = calls; + } + + result.Add(projected); + } + + continue; + } + + if (message.Role == AgentRole.Tool) + { + result.Add(new Dictionary + { + ["role"] = "tool", + ["tool_call_id"] = message.ToolCallId, + ["name"] = message.ToolName, + ["content"] = ProjectToolResult(message), + }); + } + } + + return result; + } + + private IReadOnlyList ProjectUserContent(IEnumerable content) + { + var result = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent text: + result.Add(new Dictionary { ["type"] = "text", ["text"] = SanitizeUnicode(text.Text) }); + break; + case JsonContent json: + result.Add(new Dictionary { ["type"] = "text", ["text"] = json.Json }); + break; + case BinaryContent binary when binary.MediaKind == AgentMediaKind.Image && _options.SupportsImages: + result.Add(ImageContent(binary)); + break; + case BinaryContent binary: + result.Add(new Dictionary { ["type"] = "text", ["text"] = $"(binary omitted: {binary.MediaType})" }); + break; + case ResourceContent resource: + result.Add(new Dictionary + { + ["type"] = "text", + ["text"] = $"[resource media_type={resource.MediaType}] {resource.Uri}", + }); + break; + } + } + + return result; + } + + private IReadOnlyList ProjectToolResult(AgentMessage message) + { + var result = new List(); + var text = string.Join("\n", message.Content.Select(item => item switch + { + TextContent value => value.Text, + JsonContent value => value.Json, + _ => null, + }).Where(value => value is not null)).Trim(); + var hasImages = message.Content.OfType().Any(value => value.MediaKind == AgentMediaKind.Image); + var value = BuildToolResultText(text, hasImages, _options.SupportsImages, message.IsError); + result.Add(new Dictionary { ["type"] = "text", ["text"] = value }); + if (_options.SupportsImages) + { + result.AddRange(message.Content.OfType() + .Where(binary => binary.MediaKind == AgentMediaKind.Image) + .Select(binary => (object)ImageContent(binary))); + } + + return result; + } + + private static IReadOnlyList ProjectTools(IEnumerable tools) + { + return tools.Select(tool => + { + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar) + { + throw new NotSupportedException("Mistral function tools do not support grammar-constrained sampling."); + } + + using var schema = JsonDocument.Parse(tool.InputSchemaJson); + return (object)new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = schema.RootElement.Clone(), + ["strict"] = tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema, + }, + }; + }).ToArray(); + } + + private void ApplyToolChoice(IDictionary payload) + { + if (_options.ToolChoice is null) + { + return; + } + + payload["tool_choice"] = _options.ToolChoice switch + { + MistralToolChoice.Auto => "auto", + MistralToolChoice.None => "none", + MistralToolChoice.Any => "any", + MistralToolChoice.Required => "required", + MistralToolChoice.Function => new Dictionary + { + ["type"] = "function", + ["function"] = new Dictionary { ["name"] = _options.RequiredToolName }, + }, + _ => throw new ArgumentOutOfRangeException(), + }; + } + + private void ApplyReasoning(IDictionary payload, string model, string? level) + { + if (string.IsNullOrWhiteSpace(level) || string.Equals(level, "off", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var useEffort = _options.ReasoningMode == MistralReasoningMode.Effort + || _options.ReasoningMode == MistralReasoningMode.Auto && UsesReasoningEffort(model); + if (useEffort) + { + payload["reasoning_effort"] = "high"; + } + else + { + payload["prompt_mode"] = "reasoning"; + } + } + + private static bool UsesReasoningEffort(string model) + { + var lower = model.ToLowerInvariant(); + return lower is "mistral-small-2603" or "mistral-small-latest" or "mistral-medium-3.5" or "mistral-medium-3-5"; + } + + private void ApplyHeaders(HttpRequestMessage request, string apiKey, ModelRequest modelRequest) + { + var suppressed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var header in _headers) + { + request.Headers.Remove(header.Key); + if (header.Value is null) + { + suppressed.Add(header.Key); + } + else if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + throw new InvalidOperationException($"Mistral request header '{header.Key}' is invalid."); + } + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + if (modelRequest.Parameters.CacheRetention != ModelCacheRetention.None + && !string.IsNullOrWhiteSpace(modelRequest.SessionId) + && !suppressed.Contains("x-affinity") + && !request.Headers.Contains("x-affinity")) + { + request.Headers.TryAddWithoutValidation("x-affinity", modelRequest.SessionId); + } + } + + private static Dictionary ImageContent(BinaryContent binary) => new() + { + ["type"] = "image_url", + ["image_url"] = $"data:{binary.MediaType};base64,{binary.Data}", + }; + + private static string BuildToolResultText(string text, bool hasImages, bool supportsImages, bool isError) + { + var prefix = isError ? "[tool error] " : string.Empty; + if (text.Length > 0) + { + return prefix + text + (hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : string.Empty); + } + + if (hasImages) + { + return prefix + (supportsImages ? "(see attached image)" : "(image omitted: model does not support images)"); + } + + return prefix + "(no tool output)"; + } + + private static void MergeSampling(IDictionary payload, string? json) + { + if (json is null) + { + return; + } + + using var document = JsonDocument.Parse(json); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (payload.ContainsKey(property.Name)) + { + throw new InvalidOperationException($"Sampling parameter '{property.Name}' cannot override a core request field."); + } + + payload[property.Name] = property.Value.Clone(); + } + } + + private static object ParseJsonOrString(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return value; + } + } + + private static string SanitizeUnicode(string value) + { + StringBuilder? builder = null; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (char.IsHighSurrogate(character) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) + { + if (builder is not null) + { + builder.Append(character); + builder.Append(value[++index]); + } + else + { + index++; + } + + continue; + } + + if (!char.IsSurrogate(character)) + { + builder?.Append(character); + continue; + } + + builder ??= new StringBuilder(value.Substring(0, index)); + builder.Append('\uFFFD'); + } + + return builder?.ToString() ?? value; + } + + private static async Task ReadBoundedAsync(HttpContent content, int maximumCharacters, CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var buffer = new char[Math.Min(4096, maximumCharacters)]; + var builder = new StringBuilder(); + while (builder.Length < maximumCharacters) + { + var read = await reader.ReadAsync(buffer, 0, Math.Min(buffer.Length, maximumCharacters - builder.Length)).ConfigureAwait(false); + if (read == 0) + { + break; + } + + builder.Append(buffer, 0, read); + } + + return builder.ToString(); + } + + private static async IAsyncEnumerable ReadSseDataAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var data = new StringBuilder(); + await foreach (var line in ReadBoundedLinesAsync(reader, maximumCharacters, cancellationToken)) + { + if (line.Length == 0) + { + if (data.Length > 0) + { + yield return data.ToString(); + } + + data.Clear(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + if (data.Length > 0) + { + data.Append('\n'); + } + + data.Append(line.Substring(5).TrimStart()); + if (data.Length > maximumCharacters) + { + throw new InvalidDataException("A Mistral SSE event exceeded the configured size limit."); + } + } + } + + if (data.Length > 0) + { + yield return data.ToString(); + } + } + + private static async IAsyncEnumerable ReadBoundedLinesAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(Math.Min(4096, maximumCharacters + 1)); + var line = new StringBuilder(); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (read == 0) + { + if (line.Length > 0) + { + yield return TrimCarriageReturn(line); + } + + yield break; + } + + for (var index = 0; index < read; index++) + { + if (buffer[index] == '\n') + { + yield return TrimCarriageReturn(line); + line.Clear(); + } + else + { + line.Append(buffer[index]); + if (line.Length > maximumCharacters) + { + throw new InvalidDataException("A Mistral SSE line exceeded the configured size limit."); + } + } + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string TrimCarriageReturn(StringBuilder line) + { + var length = line.Length; + if (length > 0 && line[length - 1] == '\r') + { + length--; + } + + return line.ToString(0, length); + } + + private static void ValidateOptions(MistralConversationsProviderOptions options) + { + if (!options.AllowInsecureHttp && options.Endpoint.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The Mistral endpoint must use HTTPS.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ProviderId) || string.IsNullOrWhiteSpace(options.ApiId)) + { + throw new ArgumentException("Mistral provider and API identifiers are required.", nameof(options)); + } + + if (!Enum.IsDefined(typeof(MistralReasoningMode), options.ReasoningMode) + || options.ToolChoice is { } choice && !Enum.IsDefined(typeof(MistralToolChoice), choice)) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + if (options.ToolChoice == MistralToolChoice.Function && string.IsNullOrWhiteSpace(options.RequiredToolName)) + { + throw new ArgumentException("A required Mistral function name is missing.", nameof(options)); + } + + if (options.ToolChoice != MistralToolChoice.Function && options.RequiredToolName is not null) + { + throw new ArgumentException("Only function tool choice can carry a required function name.", nameof(options)); + } + + if (options.MaxEventCharacters <= 0 + || options.MaxErrorCharacters <= 0 + || options.MaxRequestBytes <= 0 + || options.MaxResponseCharacters <= 0 + || options.MaxToolCallsPerResponse <= 0 + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "Mistral protocol limits must be positive."); + } + + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); + } +} diff --git a/src/OpenGameAgent.Providers.Mistral/MistralStreamState.cs b/src/OpenGameAgent.Providers.Mistral/MistralStreamState.cs new file mode 100644 index 0000000..d14a38f --- /dev/null +++ b/src/OpenGameAgent.Providers.Mistral/MistralStreamState.cs @@ -0,0 +1,546 @@ +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Mistral; + +internal sealed class MistralStreamState +{ + private readonly string _requestModel; + private readonly string _providerId; + private readonly string _apiId; + private readonly int _maximumCharacters; + private readonly int _maximumToolCalls; + private readonly List _blocks = new(); + private readonly Dictionary _tools = new(); + private long _characters; + private Block? _currentText; + private string? _responseId; + private string? _responseModel; + private ModelStopReason _stopReason = ModelStopReason.Pending; + private string? _rawStopReason; + private string? _errorMessage; + private ModelUsage _usage = new(); + + public MistralStreamState( + string requestModel, + string providerId, + string apiId, + int maximumCharacters, + int maximumToolCalls) + { + _requestModel = requestModel; + _providerId = providerId; + _apiId = apiId; + _maximumCharacters = maximumCharacters; + _maximumToolCalls = maximumToolCalls; + } + + public ModelResponse Partial() => BuildResponse(ModelStopReason.Pending, null, final: false); + + public IReadOnlyList Apply(string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + RequireObject(root, "A Mistral stream chunk must be an object."); + EnsureUnambiguous(root); + var id = OptionalString(root, "id"); + if (!string.IsNullOrWhiteSpace(id)) + { + _responseId ??= id; + } + + var model = OptionalString(root, "model"); + if (!string.IsNullOrWhiteSpace(model)) + { + _responseModel ??= model; + } + + if (TryProperty(root, "usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + ReadUsage(usage); + } + + var updates = new List(); + if (!TryProperty(root, "choices", out var choices)) + { + return updates; + } + + if (choices.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Mistral choices must be an array."); + } + + if (choices.GetArrayLength() == 0) + { + return updates; + } + + var choice = choices[0]; + RequireObject(choice, "A Mistral choice must be an object."); + var finishReason = OptionalString(choice, "finish_reason", "finishReason"); + if (!string.IsNullOrWhiteSpace(finishReason)) + { + _rawStopReason = finishReason; + (_stopReason, _errorMessage) = MapStopReason(finishReason!); + } + + if (TryProperty(choice, "delta", out var delta) && delta.ValueKind == JsonValueKind.Object) + { + ApplyDelta(delta, updates); + } + + return updates; + } + catch (JsonException exception) + { + throw new InvalidDataException("The Mistral stream contained invalid JSON.", exception); + } + catch (InvalidOperationException exception) + { + throw new InvalidDataException("The Mistral stream did not match the expected response shape.", exception); + } + } + + public IReadOnlyList CloseOpenBlocks() + { + var updates = new List(); + CloseCurrent(updates); + foreach (var block in _blocks.Where(value => value.Kind == BlockKind.Tool && !value.Ended)) + { + block.Ended = true; + var contentIndex = _blocks.IndexOf(block); + var partial = Partial(); + var toolCall = partial.Content[contentIndex] as ToolCallContent + ?? throw new InvalidDataException("A completed Mistral tool block did not produce a tool call."); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + partial, + contentIndex: contentIndex, + toolCall: toolCall)); + } + + return updates; + } + + public ModelResponse Complete() + { + if (_currentText is not null || _blocks.Any(value => value.Kind == BlockKind.Tool && !value.Ended)) + { + throw new InvalidDataException("The Mistral stream completed before its content blocks were closed."); + } + + if (_stopReason == ModelStopReason.Pending) + { + throw new InvalidDataException("The Mistral stream ended without a finish reason."); + } + + return BuildResponse(_stopReason, _errorMessage, final: true); + } + + private void ApplyDelta(JsonElement delta, ICollection updates) + { + if (TryProperty(delta, "content", out var content) && content.ValueKind != JsonValueKind.Null) + { + if (content.ValueKind == JsonValueKind.String) + { + AppendText(BlockKind.Text, content.GetString() ?? string.Empty, updates); + } + else if (content.ValueKind == JsonValueKind.Array) + { + foreach (var item in content.EnumerateArray()) + { + ApplyContentItem(item, updates); + } + } + else + { + throw new InvalidDataException("Mistral delta content must be a string or array."); + } + } + + if (TryProperty(delta, "tool_calls", "toolCalls", out var toolCalls) + && toolCalls.ValueKind != JsonValueKind.Null) + { + if (toolCalls.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("Mistral tool calls must be an array."); + } + + foreach (var call in toolCalls.EnumerateArray()) + { + ApplyToolCall(call, updates); + } + } + } + + private void ApplyContentItem(JsonElement item, ICollection updates) + { + if (item.ValueKind == JsonValueKind.String) + { + AppendText(BlockKind.Text, item.GetString() ?? string.Empty, updates); + return; + } + + RequireObject(item, "A Mistral content item must be an object."); + var type = RequiredString(item, "type"); + if (type == "text") + { + AppendText(BlockKind.Text, OptionalString(item, "text") ?? string.Empty, updates); + } + else if (type == "thinking") + { + var builder = new StringBuilder(); + if (TryProperty(item, "thinking", out var thinking) && thinking.ValueKind == JsonValueKind.Array) + { + foreach (var part in thinking.EnumerateArray()) + { + if (part.ValueKind == JsonValueKind.Object) + { + builder.Append(OptionalString(part, "text") ?? string.Empty); + } + } + } + + if (builder.Length > 0) + { + AppendText(BlockKind.Reasoning, builder.ToString(), updates); + } + } + } + + private void AppendText(BlockKind kind, string text, ICollection updates) + { + if (_currentText is null || _currentText.Kind != kind) + { + CloseCurrent(updates); + _currentText = new Block(kind); + _blocks.Add(_currentText); + updates.Add(ModelStreamEvent.Update( + kind == BlockKind.Reasoning ? ModelStreamEventKind.ReasoningStarted : ModelStreamEventKind.TextStarted, + Partial(), + contentIndex: _blocks.Count - 1)); + } + + AddCharacters(text.Length); + _currentText.Buffer.Append(text); + updates.Add(ModelStreamEvent.Update( + kind == BlockKind.Reasoning ? ModelStreamEventKind.ReasoningDelta : ModelStreamEventKind.TextDelta, + Partial(), + text, + _blocks.Count - 1)); + } + + private void ApplyToolCall(JsonElement call, ICollection updates) + { + RequireObject(call, "A Mistral tool call must be an object."); + CloseCurrent(updates); + var index = OptionalInt32(call, "index") ?? 0; + var id = OptionalString(call, "id"); + if (!_tools.TryGetValue(index, out var block)) + { + if (_tools.Count >= _maximumToolCalls) + { + throw new InvalidDataException("The Mistral response exceeded the configured tool-call limit."); + } + + block = new Block(BlockKind.Tool) + { + Id = !string.IsNullOrWhiteSpace(id) && id != "null" ? id : MistralToolCallIds.From("toolcall:" + index), + }; + _tools.Add(index, block); + _blocks.Add(block); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallStarted, + Partial(), + contentIndex: _blocks.Count - 1, + toolCallId: block.Id)); + } + + if (!TryProperty(call, "function", out var function) || function.ValueKind != JsonValueKind.Object) + { + return; + } + + var name = OptionalString(function, "name"); + if (!string.IsNullOrWhiteSpace(name)) + { + block.Name = name; + } + + var arguments = string.Empty; + if (TryProperty(function, "arguments", out var value) && value.ValueKind != JsonValueKind.Null) + { + arguments = value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.GetRawText(); + } + + if (arguments.Length > 0) + { + AddCharacters(arguments.Length); + block.Buffer.Append(arguments); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + arguments, + _blocks.IndexOf(block), + block.Id, + block.Name)); + } + } + + private void CloseCurrent(ICollection updates) + { + if (_currentText is null) + { + return; + } + + var block = _currentText; + updates.Add(ModelStreamEvent.Update( + block.Kind == BlockKind.Reasoning ? ModelStreamEventKind.ReasoningEnded : ModelStreamEventKind.TextEnded, + Partial(), + contentIndex: _blocks.IndexOf(block), + content: block.Buffer.ToString())); + _currentText = null; + } + + private ModelResponse BuildResponse(ModelStopReason reason, string? errorMessage, bool final) + { + var content = new List(); + foreach (var block in _blocks) + { + if (block.Kind == BlockKind.Text) + { + content.Add(new TextContent(block.Buffer.ToString())); + } + else if (block.Kind == BlockKind.Reasoning) + { + content.Add(new ReasoningContent(block.Buffer.ToString())); + } + else + { + var arguments = final + ? TryJsonObject(block.Buffer.ToString(), out var normalized) + ? normalized + : reason == ModelStopReason.Length + ? StreamingJson.ParseObject(block.Buffer.ToString()) + : "{}" + : StreamingJson.ParseObject(block.Buffer.ToString()); + if (final + && reason != ModelStopReason.Length + && !TryJsonObject(block.Buffer.ToString(), out arguments)) + { + throw new InvalidDataException("A completed Mistral tool call did not contain a JSON object."); + } + + content.Add(new ToolCallContent( + block.Id!, + string.IsNullOrWhiteSpace(block.Name) ? "unknown_tool" : block.Name!, + arguments)); + } + } + + return new ModelResponse( + content, + reason, + _usage, + errorMessage, + _providerId, + _apiId, + _responseModel ?? _requestModel, + _responseId, + _rawStopReason); + } + + private void ReadUsage(JsonElement usage) + { + var prompt = OptionalInt64(usage, "prompt_tokens", "promptTokens"); + var completion = OptionalInt64(usage, "completion_tokens", "completionTokens"); + var cached = ReadCachedTokens(usage); + _usage = new ModelUsage(Math.Max(0, prompt - cached), completion, cached); + } + + private static long ReadCachedTokens(JsonElement usage) + { + foreach (var name in new[] { "num_cached_tokens", "numCachedTokens" }) + { + if (TryProperty(usage, name, out var direct) && direct.TryGetInt64(out var value)) + { + return Math.Max(0, value); + } + } + + foreach (var name in new[] { "prompt_tokens_details", "promptTokensDetails", "prompt_token_details", "promptTokenDetails" }) + { + if (TryProperty(usage, name, out var details) && details.ValueKind == JsonValueKind.Object) + { + var value = OptionalInt64(details, "cached_tokens", "cachedTokens"); + return Math.Max(0, value); + } + } + + return 0; + } + + private void AddCharacters(int count) + { + _characters = checked(_characters + count); + if (_characters > _maximumCharacters) + { + throw new InvalidDataException("The Mistral response exceeded the configured character limit."); + } + } + + private static (ModelStopReason Reason, string? Error) MapStopReason(string reason) => reason switch + { + "stop" => (ModelStopReason.Stop, null), + "length" or "model_length" => (ModelStopReason.Length, null), + "tool_calls" => (ModelStopReason.ToolUse, null), + _ => (ModelStopReason.Error, "Provider stopped with: " + reason), + }; + + private static bool TryJsonObject(string json, out string normalized) + { + normalized = "{}"; + if (string.IsNullOrWhiteSpace(json)) + { + return true; + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + normalized = document.RootElement.GetRawText(); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("Mistral JSON objects cannot contain duplicate property names."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private static void RequireObject(JsonElement value, string message) + { + if (value.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException(message); + } + } + + private static string RequiredString(JsonElement value, string name) + { + var result = OptionalString(value, name); + return string.IsNullOrWhiteSpace(result) + ? throw new InvalidDataException("Mistral field '" + name + "' must be a non-empty string.") + : result!; + } + + private static string? OptionalString(JsonElement value, params string[] names) + { + foreach (var name in names) + { + if (!TryProperty(value, name, out var property) || property.ValueKind == JsonValueKind.Null) + { + continue; + } + + return property.ValueKind == JsonValueKind.String + ? property.GetString() + : throw new InvalidDataException("Mistral field '" + name + "' must be a string."); + } + + return null; + } + + private static int? OptionalInt32(JsonElement value, string name) + { + if (!TryProperty(value, name, out var property) || property.ValueKind == JsonValueKind.Null) + { + return null; + } + + return property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var result) && result >= 0 + ? result + : throw new InvalidDataException("Mistral field '" + name + "' must be a non-negative integer."); + } + + private static long OptionalInt64(JsonElement value, params string[] names) + { + foreach (var name in names) + { + if (!TryProperty(value, name, out var property) || property.ValueKind == JsonValueKind.Null) + { + continue; + } + + return property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var result) && result >= 0 + ? result + : throw new InvalidDataException("Mistral field '" + name + "' must be a non-negative integer."); + } + + return 0; + } + + private static bool TryProperty(JsonElement value, string name, out JsonElement property) => + value.TryGetProperty(name, out property); + + private static bool TryProperty(JsonElement value, string first, string second, out JsonElement property) => + value.TryGetProperty(first, out property) || value.TryGetProperty(second, out property); + + private enum BlockKind + { + Text, + Reasoning, + Tool, + } + + private sealed class Block + { + public Block(BlockKind kind) + { + Kind = kind; + } + + public BlockKind Kind { get; } + + public StringBuilder Buffer { get; } = new(); + + public string? Id { get; set; } + + public string? Name { get; set; } + + public bool Ended { get; set; } + } +} diff --git a/src/OpenGameAgent.Providers.Mistral/MistralToolCallIds.cs b/src/OpenGameAgent.Providers.Mistral/MistralToolCallIds.cs new file mode 100644 index 0000000..6460413 --- /dev/null +++ b/src/OpenGameAgent.Providers.Mistral/MistralToolCallIds.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Mistral; + +internal static class MistralToolCallIds +{ + private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + public static string From(string source) + { + var normalized = new string(source.Where(char.IsLetterOrDigit).ToArray()); + if (normalized.Length == 9) + { + return normalized; + } + + using var algorithm = SHA256.Create(); + var bytes = algorithm.ComputeHash(Encoding.UTF8.GetBytes(source)); + var builder = new StringBuilder(9); + var buffer = 0; + var bits = 0; + foreach (var value in bytes) + { + buffer = (buffer << 8) | value; + bits += 8; + while (bits >= 5 && builder.Length < 9) + { + bits -= 5; + builder.Append(Alphabet[(buffer >> bits) & 31]); + } + + if (builder.Length == 9) + { + break; + } + } + + return builder.ToString(); + } + + public static ProviderToolCallIdNormalizer CreateNormalizer() + { + var forward = new Dictionary(StringComparer.Ordinal); + var reverse = new Dictionary(StringComparer.Ordinal); + return (id, _, _, _) => + { + if (forward.TryGetValue(id, out var existing)) + { + return existing; + } + + for (var attempt = 0; ; attempt++) + { + var candidate = From(attempt == 0 ? id : id + ":" + attempt.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (!reverse.TryGetValue(candidate, out var owner) || owner == id) + { + forward[id] = candidate; + reverse[candidate] = id; + return candidate; + } + } + }; + } +} diff --git a/src/OpenGameAgent.Providers.Mistral/OpenGameAgent.Providers.Mistral.csproj b/src/OpenGameAgent.Providers.Mistral/OpenGameAgent.Providers.Mistral.csproj new file mode 100644 index 0000000..ffa6caa --- /dev/null +++ b/src/OpenGameAgent.Providers.Mistral/OpenGameAgent.Providers.Mistral.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + OpenGameAgent.Providers.Mistral + Native Mistral chat transport for OpenGameAgent. + + + + + + + + + diff --git a/src/OpenGameAgent.Providers.Mistral/packages.lock.json b/src/OpenGameAgent.Providers.Mistral/packages.lock.json new file mode 100644 index 0000000..775e1fc --- /dev/null +++ b/src/OpenGameAgent.Providers.Mistral/packages.lock.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.OpenAI/AzureOpenAIResponses.cs b/src/OpenGameAgent.Providers.OpenAI/AzureOpenAIResponses.cs new file mode 100644 index 0000000..33aaf61 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/AzureOpenAIResponses.cs @@ -0,0 +1,100 @@ +namespace OpenGameAgent.Providers.OpenAI; + +public static class AzureOpenAIResponses +{ + public static OpenAIResponsesProviderOptions CreateOptions( + HttpClient httpClient, + string baseUrl, + string? apiKey = null, + string apiVersion = "v1") + { + if (string.IsNullOrWhiteSpace(apiVersion)) + { + throw new ArgumentException("An API version is required.", nameof(apiVersion)); + } + + return new OpenAIResponsesProviderOptions(httpClient, BuildResponsesEndpoint(baseUrl, apiVersion)) + { + ApiKey = apiKey, + AuthenticationStyle = OpenAIAuthenticationStyle.ApiKeyHeader, + ProviderId = "azure-openai-responses", + ApiId = "azure-openai-responses", + SupportsDeveloperRole = true, + SupportsStrictTools = true, + SupportsLongCacheRetention = false, + }; + } + + public static OpenAIResponsesProviderOptions CreateOptionsForResource( + HttpClient httpClient, + string resourceName, + string? apiKey = null, + string apiVersion = "v1") + { + if (string.IsNullOrWhiteSpace(resourceName) + || resourceName.Any(character => !char.IsLetterOrDigit(character) && character != '-')) + { + throw new ArgumentException("A resource name may contain only letters, digits, and hyphens.", nameof(resourceName)); + } + + return CreateOptions( + httpClient, + "https://" + resourceName + ".openai.azure.com/openai/v1", + apiKey, + apiVersion); + } + + public static Uri BuildResponsesEndpoint(string baseUrl, string apiVersion = "v1") + { + if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var parsed) + || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) + || parsed.UserInfo.Length > 0) + { + throw new ArgumentException("The base URL is invalid.", nameof(baseUrl)); + } + + var builder = new UriBuilder(parsed); + var path = builder.Path.TrimEnd('/'); + var hosted = builder.Host.EndsWith(".openai.azure.com", StringComparison.OrdinalIgnoreCase) + || builder.Host.EndsWith(".cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase) + || builder.Host.EndsWith(".ai.azure.com", StringComparison.OrdinalIgnoreCase); + if (hosted && (path.Length == 0 + || path == "/openai" + || path == "/openai/v1" + || path == "/openai/v1/responses")) + { + path = "/openai/v1"; + builder.Query = string.Empty; + } + + if (!path.EndsWith("/responses", StringComparison.OrdinalIgnoreCase)) + { + path += "/responses"; + } + + builder.Path = path; + var query = ParseQuery(builder.Query); + if (hosted) + { + query["api-version"] = apiVersion; + } + + builder.Query = string.Join("&", query.Select(pair => + Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))); + return builder.Uri; + } + + private static IDictionary ParseQuery(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in query.TrimStart('?').Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries)) + { + var pieces = part.Split(new[] { '=' }, 2); + result[Uri.UnescapeDataString(pieces[0])] = pieces.Length == 2 + ? Uri.UnescapeDataString(pieces[1]) + : string.Empty; + } + + return result; + } +} diff --git a/src/OpenGameAgent.Providers.OpenAI/OpenAICodexResponses.cs b/src/OpenGameAgent.Providers.OpenAI/OpenAICodexResponses.cs new file mode 100644 index 0000000..239befd --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/OpenAICodexResponses.cs @@ -0,0 +1,152 @@ +using System.Text; +using System.Text.Json; + +namespace OpenGameAgent.Providers.OpenAI; + +public static class OpenAICodexResponses +{ + public static readonly Uri DefaultEndpoint = new("https://chatgpt.com/backend-api/codex/responses"); + + public static OpenAIResponsesProviderOptions CreateOptions( + HttpClient httpClient, + string accessToken, + Uri? endpoint = null, + bool supportsAdditionalTools = true, + bool supportsToolSearch = true) + { + var credential = PrepareCredential(new OpenAIRequestCredential(accessToken)); + var options = CreateBaseOptions( + httpClient, + endpoint, + supportsAdditionalTools, + supportsToolSearch); + options.ApiKey = credential.ApiKey; + foreach (var header in credential.Headers) + { + options.Headers[header.Key] = header.Value; + } + + return options; + } + + public static OpenAIResponsesProviderOptions CreateOptions( + HttpClient httpClient, + OpenAIRequestCredentialProvider getCredentialAsync, + Uri? endpoint = null, + bool supportsAdditionalTools = true, + bool supportsToolSearch = true) + { + if (getCredentialAsync is null) + { + throw new ArgumentNullException(nameof(getCredentialAsync)); + } + + var options = CreateBaseOptions( + httpClient, + endpoint, + supportsAdditionalTools, + supportsToolSearch); + options.GetCredentialAsync = async cancellationToken => + { + var credential = await getCredentialAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The credential provider returned null."); + return PrepareCredential(credential); + }; + return options; + } + + public static string ExtractAccountId(string accessToken) + { + if (string.IsNullOrWhiteSpace(accessToken) || accessToken.Length > 65_536) + { + throw new ArgumentException("A bounded access token is required.", nameof(accessToken)); + } + + try + { + var parts = accessToken.Split('.'); + if (parts.Length != 3) + { + throw new FormatException(); + } + + var encoded = parts[1].Replace('-', '+').Replace('_', '/'); + encoded = encoded.PadRight(encoded.Length + (4 - encoded.Length % 4) % 4, '='); + var bytes = Convert.FromBase64String(encoded); + if (bytes.Length > 1_000_000) + { + throw new FormatException(); + } + + using var document = JsonDocument.Parse(bytes); + var accountId = document.RootElement + .GetProperty("https://api.openai.com/auth") + .GetProperty("chatgpt_account_id") + .GetString(); + if (string.IsNullOrWhiteSpace(accountId) + || accountId.Length > 512 + || accountId.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new FormatException(); + } + + return accountId; + } + catch (Exception exception) when (exception is FormatException + or JsonException + or KeyNotFoundException + or InvalidOperationException) + { + throw new ArgumentException("The access token does not contain a valid account identifier.", nameof(accessToken), exception); + } + } + + private static OpenAIResponsesProviderOptions CreateBaseOptions( + HttpClient httpClient, + Uri? endpoint, + bool supportsAdditionalTools, + bool supportsToolSearch) + { + var options = new OpenAIResponsesProviderOptions(httpClient, endpoint ?? DefaultEndpoint) + { + ProviderId = "openai-codex", + ApiId = "openai-codex-responses", + AuthenticationStyle = OpenAIAuthenticationStyle.Bearer, + SystemPromptMode = OpenAISystemPromptMode.Instructions, + DefaultInstructions = "You are a helpful assistant.", + ReasoningSummary = "auto", + TextVerbosity = OpenAITextVerbosity.Low, + ToolChoice = OpenAIToolChoice.Auto, + ParallelToolCalls = true, + AlwaysIncludeEncryptedReasoning = true, + SupportsDeveloperRole = false, + SupportsStrictTools = true, + SupportsGrammarTools = true, + SupportsAdditionalTools = supportsAdditionalTools, + SupportsToolSearch = supportsToolSearch, + SupportsLongCacheRetention = false, + SupportsWebSocketTransport = true, + SessionAffinityFormat = OpenAISessionAffinityFormat.Codex, + }; + options.Headers["OpenAI-Beta"] = "responses=experimental"; + options.Headers["originator"] = "opengameagent"; + options.Headers["Accept"] = "text/event-stream"; + return options; + } + + private static OpenAIRequestCredential PrepareCredential(OpenAIRequestCredential credential) + { + if (credential is null) + { + throw new ArgumentNullException(nameof(credential)); + } + + var token = credential.ApiKey; + var accountId = ExtractAccountId(token ?? string.Empty); + var headers = new Dictionary(credential.Headers, StringComparer.OrdinalIgnoreCase) + { + ["chatgpt-account-id"] = accountId, + }; + return new OpenAIRequestCredential(token, headers); + } +} diff --git a/src/OpenGameAgent.Providers.OpenAI/OpenAIResponsesProvider.cs b/src/OpenGameAgent.Providers.OpenAI/OpenAIResponsesProvider.cs new file mode 100644 index 0000000..fc0b51d --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/OpenAIResponsesProvider.cs @@ -0,0 +1,2565 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; + +namespace OpenGameAgent.Providers.OpenAI; + +public delegate ValueTask OpenAIApiKeyProvider(CancellationToken cancellationToken); + +public enum OpenAISessionAffinityFormat +{ + OpenAI, + OpenAIWithoutSessionHeader, + OpenRouter, + Codex, +} + +public enum OpenAIAuthenticationStyle +{ + Bearer, + ApiKeyHeader, + None, +} + +public enum OpenAISystemPromptMode +{ + InputMessage, + Instructions, +} + +public enum OpenAIToolChoice +{ + Auto, + None, + Required, +} + +public enum OpenAITextVerbosity +{ + Low, + Medium, + High, +} + +public sealed class OpenAIRequestCredential +{ + public OpenAIRequestCredential( + string? apiKey, + IReadOnlyDictionary? headers = null) + { + ApiKey = apiKey; + Headers = new ReadOnlyDictionary( + new Dictionary( + headers ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase)); + } + + public string? ApiKey { get; } + + public IReadOnlyDictionary Headers { get; } +} + +public delegate ValueTask OpenAIRequestCredentialProvider( + CancellationToken cancellationToken); + +public sealed class OpenAIResponsesProviderOptions +{ + public OpenAIResponsesProviderOptions(HttpClient httpClient, Uri endpoint) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public string? ApiKey { get; set; } + + public OpenAIApiKeyProvider? GetApiKeyAsync { get; set; } + + public OpenAIRequestCredentialProvider? GetCredentialAsync { get; set; } + + public OpenAIAuthenticationStyle AuthenticationStyle { get; set; } = OpenAIAuthenticationStyle.Bearer; + + public string ApiKeyHeaderName { get; set; } = "api-key"; + + public OpenAISystemPromptMode SystemPromptMode { get; set; } = OpenAISystemPromptMode.InputMessage; + + public string DefaultInstructions { get; set; } = "You are a helpful assistant."; + + public string? ReasoningSummary { get; set; } + + public string? ServiceTier { get; set; } + + public OpenAITextVerbosity? TextVerbosity { get; set; } + + public OpenAIToolChoice? ToolChoice { get; set; } + + public bool? ParallelToolCalls { get; set; } + + public bool AlwaysIncludeEncryptedReasoning { get; set; } + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; + + public string ProviderId { get; set; } = "openai"; + + public string ApiId { get; set; } = "openai-responses"; + + public bool AllowInsecureHttp { get; set; } + + public bool SupportsDeveloperRole { get; set; } = true; + + public bool SupportsStrictTools { get; set; } + + public bool SupportsGrammarTools { get; set; } + + public bool SupportsAdditionalTools { get; set; } + + public bool SupportsToolSearch { get; set; } + + public bool SupportsExplicitPromptCacheMode { get; set; } + + public bool SupportsLongCacheRetention { get; set; } = true; + + public bool SupportsWebSocketTransport { get; set; } + + public OpenAIWebSocketConnectionFactory? WebSocketConnectionFactory { get; set; } + + public int WebSocketIdleTimeoutMilliseconds { get; set; } = 300_000; + + public int WebSocketSessionIdleTimeoutMilliseconds { get; set; } = 300_000; + + public int WebSocketMaximumConnectionAgeMilliseconds { get; set; } = 3_300_000; + + public OpenAISessionAffinityFormat SessionAffinityFormat { get; set; } = OpenAISessionAffinityFormat.OpenAI; + + public int MaxEventCharacters { get; set; } = 4_000_000; + + public int MaxErrorCharacters { get; set; } = 64_000; + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseCharacters { get; set; } = 16_000_000; + + public int MaxToolCallsPerResponse { get; set; } = 256; +} + +public sealed class OpenAIResponsesProvider : IModelProvider, IModelProviderCapabilities, IDisposable +{ + private const int MinimumOutputTokens = 16; + private const string WebSocketBetaHeader = "responses_websockets=2026-02-06"; + private readonly HttpClient _httpClient; + private readonly Uri _endpoint; + private readonly string? _apiKey; + private readonly OpenAIApiKeyProvider? _getApiKeyAsync; + private readonly OpenAIRequestCredentialProvider? _getCredentialAsync; + private readonly OpenAIAuthenticationStyle _authenticationStyle; + private readonly string _apiKeyHeaderName; + private readonly OpenAISystemPromptMode _systemPromptMode; + private readonly string _defaultInstructions; + private readonly string? _reasoningSummary; + private readonly string? _serviceTier; + private readonly OpenAITextVerbosity? _textVerbosity; + private readonly OpenAIToolChoice? _toolChoice; + private readonly bool? _parallelToolCalls; + private readonly bool _alwaysIncludeEncryptedReasoning; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; + private readonly string _providerId; + private readonly string _apiId; + private readonly bool _supportsDeveloperRole; + private readonly bool _supportsStrictTools; + private readonly bool _supportsGrammarTools; + private readonly bool _supportsAdditionalTools; + private readonly bool _supportsToolSearch; + private readonly bool _supportsExplicitPromptCacheMode; + private readonly bool _supportsLongCacheRetention; + private readonly OpenAIWebSocketConnectionFactory? _webSocketConnectionFactory; + private readonly int _webSocketIdleTimeoutMilliseconds; + private readonly int _webSocketSessionIdleTimeoutMilliseconds; + private readonly int _webSocketMaximumConnectionAgeMilliseconds; + private readonly OpenAISessionAffinityFormat _sessionAffinityFormat; + private readonly int _maxEventCharacters; + private readonly int _maxErrorCharacters; + private readonly int _maxRequestBytes; + private readonly int _maxResponseCharacters; + private readonly int _maxToolCallsPerResponse; + private readonly IReadOnlyCollection _supportedApis; + private readonly object _webSocketGate = new(); + private readonly Dictionary> _webSocketSessions = + new(StringComparer.Ordinal); + private readonly Dictionary _webSocketStatistics = + new(StringComparer.Ordinal); + private readonly HashSet _webSocketFallbackSessions = new(StringComparer.Ordinal); + private bool _disposed; + + public OpenAIResponsesProvider(OpenAIResponsesProviderOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + ValidateOptions(options); + _httpClient = options.HttpClient; + _endpoint = options.Endpoint; + _apiKey = options.ApiKey; + _getApiKeyAsync = options.GetApiKeyAsync; + _getCredentialAsync = options.GetCredentialAsync; + _authenticationStyle = options.AuthenticationStyle; + _apiKeyHeaderName = options.ApiKeyHeaderName; + _systemPromptMode = options.SystemPromptMode; + _defaultInstructions = options.DefaultInstructions; + _reasoningSummary = options.ReasoningSummary; + _serviceTier = options.ServiceTier; + _textVerbosity = options.TextVerbosity; + _toolChoice = options.ToolChoice; + _parallelToolCalls = options.ParallelToolCalls; + _alwaysIncludeEncryptedReasoning = options.AlwaysIncludeEncryptedReasoning; + _headers = new ReadOnlyDictionary( + new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase)); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; + _providerId = options.ProviderId; + _apiId = options.ApiId; + _supportsDeveloperRole = options.SupportsDeveloperRole; + _supportsStrictTools = options.SupportsStrictTools; + _supportsGrammarTools = options.SupportsGrammarTools; + _supportsAdditionalTools = options.SupportsAdditionalTools; + _supportsToolSearch = options.SupportsToolSearch; + _supportsExplicitPromptCacheMode = options.SupportsExplicitPromptCacheMode; + _supportsLongCacheRetention = options.SupportsLongCacheRetention; + _webSocketConnectionFactory = options.SupportsWebSocketTransport + ? options.WebSocketConnectionFactory ?? ClientOpenAIWebSocketConnection.ConnectAsync + : null; + _webSocketIdleTimeoutMilliseconds = options.WebSocketIdleTimeoutMilliseconds; + _webSocketSessionIdleTimeoutMilliseconds = options.WebSocketSessionIdleTimeoutMilliseconds; + _webSocketMaximumConnectionAgeMilliseconds = options.WebSocketMaximumConnectionAgeMilliseconds; + _sessionAffinityFormat = options.SessionAffinityFormat; + _maxEventCharacters = options.MaxEventCharacters; + _maxErrorCharacters = options.MaxErrorCharacters; + _maxRequestBytes = options.MaxRequestBytes; + _maxResponseCharacters = options.MaxResponseCharacters; + _maxToolCallsPerResponse = options.MaxToolCallsPerResponse; + _supportedApis = Array.AsReadOnly(new[] { _apiId }); + } + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => _supportsAdditionalTools || _supportsToolSearch; + + public bool SupportsDeferredResponses => false; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + ThrowIfDisposed(); + var credential = await ResolveCredentialAsync(cancellationToken).ConfigureAwait(false); + var transport = request.Parameters.Transport; + if (transport == ModelTransport.ServerSentEvents + || transport == ModelTransport.Auto && _webSocketConnectionFactory is null) + { + await foreach (var streamEvent in StreamSseAsync(request, credential, null, cancellationToken) + .ConfigureAwait(false)) + { + yield return streamEvent; + } + + yield break; + } + + if (_webSocketConnectionFactory is null) + { + throw new NotSupportedException("This provider does not support the requested WebSocket transport."); + } + + var cacheSessionId = CacheSessionId(request); + if (cacheSessionId is not null && IsWebSocketFallbackActive(cacheSessionId)) + { + RecordWebSocketSseFallback(cacheSessionId); + var diagnostic = TransportDiagnostic( + transport, + "The session previously encountered a WebSocket transport failure and is using server-sent events."); + await foreach (var streamEvent in StreamSseAsync(request, credential, diagnostic, cancellationToken) + .ConfigureAwait(false)) + { + yield return streamEvent; + } + + yield break; + } + + var webSocketStream = StreamWebSocketWithRecoveryAsync(request, credential, cancellationToken); + var enumerator = webSocketStream.GetAsyncEnumerator(cancellationToken); + var moved = false; + ModelStreamEvent? current = null; + Exception? failure = null; + try + { + moved = await enumerator.MoveNextAsync().ConfigureAwait(false); + if (moved) + { + current = enumerator.Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await enumerator.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception exception) + { + failure = exception; + } + + if (failure is not null || !moved || current is null) + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + failure ??= new InvalidDataException("The WebSocket stream ended before its first event."); + if (!CanFallbackToServerSentEvents(failure)) + { + throw failure; + } + + RecordWebSocketFailure(cacheSessionId, failure); + RecordWebSocketSseFallback(cacheSessionId); + var diagnostic = TransportDiagnostic(transport, BoundExceptionMessage(failure)); + await foreach (var streamEvent in StreamSseAsync(request, credential, diagnostic, cancellationToken) + .ConfigureAwait(false)) + { + yield return streamEvent; + } + + yield break; + } + + try + { + while (true) + { + yield return current; + if (current.IsTerminal) + { + yield break; + } + + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + throw new InvalidDataException("The WebSocket stream ended without a terminal response."); + } + + current = enumerator.Current + ?? throw new InvalidDataException("The WebSocket provider emitted a null event."); + } + } + finally + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + } + } + + private async IAsyncEnumerable StreamSseAsync( + ModelRequest request, + OpenAIRequestCredential credential, + ModelDiagnostic? transportDiagnostic, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _endpoint); + ApplyHeaders(httpRequest, credential, request); + httpRequest.Content = new ByteArrayContent(SerializeRequest(request)); + httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + + using var response = await _httpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromHttpResponse( + _providerId, + _apiId, + request.Model, + response), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var error = await ReadBoundedAsync(response.Content, _maxErrorCharacters, cancellationToken).ConfigureAwait(false); + var retry = ProviderHttpRetryMetadata.FromResponse(response, errorText: error); + throw new ModelProviderException( + $"The Responses endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", + retry.IsTransient, + retry.RetryAfter, + (int)response.StatusCode); + } + + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var cancellationRegistration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var state = new ResponsesStreamState( + request.Model, + _providerId, + _apiId, + GrammarInputProperties(request.Tools), + _maxResponseCharacters, + _maxToolCallsPerResponse); + if (transportDiagnostic is not null) + { + state.AddDiagnostic(transportDiagnostic); + } + + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + + await foreach (var line in ReadBoundedLinesAsync(reader, _maxEventCharacters, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var data = line.Substring(5).TrimStart(); + if (data.Length == 0 || data == "[DONE]") + { + continue; + } + + foreach (var item in state.Apply(data)) + { + yield return item; + } + + if (state.IsTerminal) + { + break; + } + } + + yield return ModelStreamEvent.Terminal(state.Complete()); + } + + private async IAsyncEnumerable StreamWebSocketWithRecoveryAsync( + ModelRequest request, + OpenAIRequestCredential credential, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var retriedConnectionLimit = false; + var retriedMissingContinuation = false; + var forceFullContext = false; + while (true) + { + var attempt = StreamWebSocketAttemptAsync( + request, + credential, + forceFullContext, + cancellationToken); + var enumerator = attempt.GetAsyncEnumerator(cancellationToken); + var moved = false; + ModelStreamEvent? current = null; + Exception? failure = null; + try + { + moved = await enumerator.MoveNextAsync().ConfigureAwait(false); + if (moved) + { + current = enumerator.Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + throw; + } + catch (Exception exception) + { + failure = exception; + } + + if (failure is OpenAIWebSocketProtocolException protocolFailure + && string.Equals( + protocolFailure.Code, + "previous_response_not_found", + StringComparison.OrdinalIgnoreCase) + && !retriedMissingContinuation) + { + retriedMissingContinuation = true; + forceFullContext = true; + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + continue; + } + + if (failure is OpenAIWebSocketProtocolException limitFailure + && string.Equals( + limitFailure.Code, + "websocket_connection_limit_reached", + StringComparison.OrdinalIgnoreCase) + && !retriedConnectionLimit) + { + retriedConnectionLimit = true; + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + continue; + } + + if (failure is not null) + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + throw failure; + } + + if (!moved || current is null) + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + throw new InvalidDataException("The WebSocket attempt ended before its first event."); + } + + try + { + while (true) + { + yield return current; + if (current.IsTerminal) + { + yield break; + } + + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + throw new InvalidDataException( + "The WebSocket attempt ended without a terminal response."); + } + + current = enumerator.Current + ?? throw new InvalidDataException("The WebSocket attempt emitted a null event."); + } + } + finally + { + await DisposeIgnoringFailureAsync(enumerator).ConfigureAwait(false); + } + } + } + + private async IAsyncEnumerable StreamWebSocketAttemptAsync( + ModelRequest request, + OpenAIRequestCredential credential, + bool forceFullContext, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var cacheSessionId = CacheSessionId(request); + var accountId = AccountId(credential); + var lease = await AcquireWebSocketAsync( + request, + credential, + cacheSessionId, + accountId, + cancellationToken) + .ConfigureAwait(false); + var completed = false; + try + { + var body = SerializeRequest(request); + var bodySnapshot = RequestBodySnapshot.Create(body); + var useCachedContext = request.Parameters.Transport is ModelTransport.Auto or ModelTransport.CachedWebSocket; + RequestBodyDelta? delta = null; + if (useCachedContext && !forceFullContext && lease.Entry?.Continuation is { } continuation) + { + delta = bodySnapshot.TryCreateDelta(continuation); + if (delta is null) + { + lease.Entry.Continuation = null; + } + } + + var requestJson = bodySnapshot.CreateWebSocketRequest(delta); + if (Encoding.UTF8.GetByteCount(requestJson) > _maxRequestBytes) + { + throw new InvalidDataException("The WebSocket request exceeded the configured byte limit."); + } + + var state = new ResponsesStreamState( + request.Model, + _providerId, + _apiId, + GrammarInputProperties(request.Tools), + _maxResponseCharacters, + _maxToolCallsPerResponse); + RecordWebSocketRequest(cacheSessionId, lease.Reused, delta is not null); + await AwaitWithCancellationAsync( + lease.Connection.SendTextAsync(requestJson, cancellationToken).AsTask(), + cancellationToken) + .ConfigureAwait(false); + var started = false; + while (!state.IsTerminal) + { + var json = await ReceiveWebSocketEventAsync(lease.Connection, cancellationToken) + .ConfigureAwait(false); + ThrowIfWebSocketProtocolError(json); + var updates = state.Apply(json); + if (!started && (updates.Count > 0 || state.IsTerminal)) + { + started = true; + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + } + + foreach (var update in updates) + { + yield return update; + } + } + + var response = state.Complete(); + if (!started) + { + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); + } + + if (useCachedContext && lease.Entry is not null && response.ResponseId is { } responseId) + { + lease.Entry.Continuation = new WebSocketContinuation( + bodySnapshot.Fingerprint, + responseId, + bodySnapshot.InputItems.Concat(ProjectResponseItems(request, response)).ToArray()); + } + + completed = true; + yield return ModelStreamEvent.Terminal(response); + } + finally + { + if (!completed && lease.Entry is not null) + { + lease.Entry.Continuation = null; + } + + lease.Release(completed); + } + } + + private async ValueTask ReceiveWebSocketEventAsync( + IOpenAIWebSocketConnection connection, + CancellationToken cancellationToken) + { + using var timeout = new CancellationTokenSource(_webSocketIdleTimeoutMilliseconds); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + try + { + return await AwaitWithCancellationAsync( + connection.ReceiveTextAsync(_maxEventCharacters, linked.Token).AsTask(), + linked.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException exception) when ( + timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"WebSocket idle timeout after {_webSocketIdleTimeoutMilliseconds}ms.", + exception); + } + } + + private async ValueTask AcquireWebSocketAsync( + ModelRequest request, + OpenAIRequestCredential credential, + string? sessionId, + string accountId, + CancellationToken cancellationToken) + { + CachedWebSocketConnection? reusable = null; + CachedWebSocketConnection? stale = null; + if (sessionId is not null) + { + lock (_webSocketGate) + { + if (_webSocketSessions.TryGetValue(sessionId, out var accounts) + && accounts.TryGetValue(accountId, out var cached)) + { + if (!cached.Busy + && (DateTimeOffset.UtcNow - cached.CreatedAt).TotalMilliseconds + >= _webSocketMaximumConnectionAgeMilliseconds) + { + accounts.Remove(accountId); + if (accounts.Count == 0) + { + _webSocketSessions.Remove(sessionId); + } + + stale = cached; + } + else if (!cached.Busy && cached.Connection.IsOpen) + { + cached.Busy = true; + cached.IdleTimer?.Dispose(); + cached.IdleTimer = null; + reusable = cached; + } + else if (!cached.Busy && !cached.Connection.IsOpen) + { + accounts.Remove(accountId); + if (accounts.Count == 0) + { + _webSocketSessions.Remove(sessionId); + } + + stale = cached; + } + } + } + } + + stale?.Dispose(); + if (reusable is not null) + { + return new WebSocketLease( + reusable.Connection, + reusable, + reused: true, + keep => ReleaseCachedWebSocket(sessionId!, accountId, reusable, keep)); + } + + var headers = BuildWebSocketHeaders(credential, request); + var endpoint = WebSocketEndpoint(_endpoint); + var connectRequest = new OpenAIWebSocketConnectRequest( + endpoint, + headers, + request.Parameters.WebSocketConnectTimeoutMilliseconds); + using var connectTimeout = connectRequest.TimeoutMilliseconds is { } connectMilliseconds + ? new CancellationTokenSource(connectMilliseconds) + : null; + using var connectCancellation = connectTimeout is null + ? null + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectTimeout.Token); + var connectToken = connectCancellation?.Token ?? cancellationToken; + var connectTask = _webSocketConnectionFactory!(connectRequest, connectToken).AsTask(); + IOpenAIWebSocketConnection connection = null!; + try + { + connection = await AwaitWithCancellationAsync( + connectTask, + connectToken, + lateResult => lateResult?.Dispose()) + .ConfigureAwait(false) + ?? throw new InvalidOperationException("The WebSocket connection factory returned null."); + + if (connection is IOpenAIWebSocketResponseMetadata metadata) + { + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromResponseMetadata( + _providerId, + _apiId, + request.Model, + metadata.HandshakeStatusCode, + metadata.HandshakeHeaders), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException exception) when ( + connectTimeout?.IsCancellationRequested == true && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"WebSocket connect timeout after {connectRequest.TimeoutMilliseconds}ms.", + exception); + } + catch + { + connection?.Dispose(); + throw; + } + + if (sessionId is null) + { + return new WebSocketLease( + connection, + entry: null, + reused: false, + _ => connection.Dispose()); + } + + CachedWebSocketConnection? entry = null; + lock (_webSocketGate) + { + if (!_webSocketSessions.TryGetValue(sessionId, out var accounts)) + { + accounts = new Dictionary(StringComparer.Ordinal); + _webSocketSessions[sessionId] = accounts; + } + + if (!accounts.ContainsKey(accountId)) + { + entry = new CachedWebSocketConnection(connection); + accounts[accountId] = entry; + } + } + + if (entry is null) + { + return new WebSocketLease( + connection, + entry: null, + reused: false, + _ => connection.Dispose()); + } + + return new WebSocketLease( + connection, + entry, + reused: false, + keep => ReleaseCachedWebSocket(sessionId, accountId, entry, keep)); + } + + private void ReleaseCachedWebSocket( + string sessionId, + string accountId, + CachedWebSocketConnection entry, + bool keep) + { + var dispose = false; + lock (_webSocketGate) + { + if (!_webSocketSessions.TryGetValue(sessionId, out var accounts) + || !ReferenceEquals(accounts.GetValueOrDefault(accountId), entry)) + { + dispose = true; + } + else if (!keep || !entry.Connection.IsOpen) + { + accounts.Remove(accountId); + if (accounts.Count == 0) + { + _webSocketSessions.Remove(sessionId); + } + + dispose = true; + } + else + { + entry.Busy = false; + entry.IdleTimer?.Dispose(); + entry.IdleTimer = new Timer( + _ => ExpireWebSocket(sessionId, accountId, entry), + null, + _webSocketSessionIdleTimeoutMilliseconds, + Timeout.Infinite); + } + } + + if (dispose) + { + entry.Dispose(); + } + } + + private void ExpireWebSocket(string sessionId, string accountId, CachedWebSocketConnection entry) + { + var dispose = false; + lock (_webSocketGate) + { + if (!entry.Busy + && _webSocketSessions.TryGetValue(sessionId, out var accounts) + && ReferenceEquals(accounts.GetValueOrDefault(accountId), entry)) + { + accounts.Remove(accountId); + if (accounts.Count == 0) + { + _webSocketSessions.Remove(sessionId); + } + + dispose = true; + } + } + + if (dispose) + { + entry.Dispose(); + } + } + + public OpenAIWebSocketStatistics? GetWebSocketStatistics(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException("A session identifier is required.", nameof(sessionId)); + } + + lock (_webSocketGate) + { + return _webSocketStatistics.TryGetValue(sessionId, out var value) + ? value.Snapshot(_webSocketFallbackSessions.Contains(sessionId)) + : null; + } + } + + public void ResetWebSocketStatistics(string? sessionId = null) + { + lock (_webSocketGate) + { + if (sessionId is null) + { + _webSocketStatistics.Clear(); + _webSocketFallbackSessions.Clear(); + return; + } + + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException("A session identifier cannot be empty.", nameof(sessionId)); + } + + _webSocketStatistics.Remove(sessionId); + _webSocketFallbackSessions.Remove(sessionId); + } + } + + public void CloseWebSocketSessions(string? sessionId = null) + { + List entries; + lock (_webSocketGate) + { + if (sessionId is not null) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException("A session identifier cannot be empty.", nameof(sessionId)); + } + + entries = _webSocketSessions.TryGetValue(sessionId, out var accounts) + ? accounts.Values.ToList() + : new List(); + _webSocketSessions.Remove(sessionId); + _webSocketFallbackSessions.Remove(sessionId); + } + else + { + entries = _webSocketSessions.Values.SelectMany(accounts => accounts.Values).ToList(); + _webSocketSessions.Clear(); + _webSocketFallbackSessions.Clear(); + } + } + + foreach (var entry in entries) + { + entry.Dispose(); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + CloseWebSocketSessions(); + } + + private async ValueTask ResolveCredentialAsync( + CancellationToken cancellationToken) + { + if (_getCredentialAsync is not null) + { + return await ProviderCallbackRunner.RunAsync( + token => _getCredentialAsync(token), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The credential provider returned null."); + } + + var apiKey = _getApiKeyAsync is null + ? _apiKey + : await ProviderCallbackRunner.RunAsync( + token => _getApiKeyAsync(token), + cancellationToken) + .ConfigureAwait(false); + return new OpenAIRequestCredential(apiKey); + } + + private IReadOnlyDictionary BuildWebSocketHeaders( + OpenAIRequestCredential credential, + ModelRequest request) + { + using var message = new HttpRequestMessage(HttpMethod.Post, _endpoint); + ApplyHeaders(message, credential, request); + var headers = message.Headers.ToDictionary( + header => header.Key, + header => string.Join(",", header.Value), + StringComparer.OrdinalIgnoreCase); + headers.Remove("Accept"); + headers.Remove("Content-Type"); + headers.Remove("OpenAI-Beta"); + headers["OpenAI-Beta"] = WebSocketBetaHeader; + var requestId = request.Parameters.CacheRetention == ModelCacheRetention.None + ? Guid.NewGuid().ToString("N") + : request.SessionId is { Length: > 0 } sessionId + ? ClampUnicode(sessionId, 64) + : Guid.NewGuid().ToString("N"); + headers["session-id"] = requestId; + headers["x-client-request-id"] = requestId; + return new ReadOnlyDictionary(headers); + } + + private static Uri WebSocketEndpoint(Uri endpoint) + { + var builder = new UriBuilder(endpoint) + { + Scheme = endpoint.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", + Port = endpoint.IsDefaultPort ? -1 : endpoint.Port, + }; + return builder.Uri; + } + + private static string AccountId(OpenAIRequestCredential credential) + { + if (credential.Headers.TryGetValue("chatgpt-account-id", out var accountId) + && !string.IsNullOrWhiteSpace(accountId)) + { + return accountId; + } + + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(credential.ApiKey ?? string.Empty)); + return Convert.ToBase64String(bytes); + } + + private static string? CacheSessionId(ModelRequest request) => + request.Parameters.CacheRetention == ModelCacheRetention.None + ? null + : request.SessionId; + + private IReadOnlyList ProjectResponseItems(ModelRequest request, ModelResponse response) + { + var assistant = new AgentMessage( + AgentRole.Assistant, + response.Content, + DateTimeOffset.UtcNow, + model: request.Model, + stopReason: response.StopReason, + usage: response.Usage, + errorMessage: response.ErrorMessage, + provider: response.Provider, + api: response.Api, + responseModel: response.ResponseModel, + responseId: response.ResponseId, + rawStopReason: response.RawStopReason, + endTurn: response.EndTurn, + diagnostics: response.Diagnostics, + deferred: response.Deferred); + var normalized = ProviderTranscript.Normalize( + new[] { assistant }, + _providerId, + _apiId, + request.Model, + (id, _, _, _) => + { + var identity = NormalizeToolIdentity(id, sameProtocol: true, sameModel: true); + return identity.CallId + "|" + identity.ItemId; + }); + return ProjectInput( + request, + normalized, + new ReadOnlyDictionary( + new Dictionary(StringComparer.Ordinal)), + includeSystemPrompt: false) + .Select(item => JsonSerializer.Serialize(item)) + .ToArray(); + } + + private static void ThrowIfWebSocketProtocolError(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("type", out var type) + || type.ValueKind != JsonValueKind.String) + { + return; + } + + var eventType = type.GetString(); + var errorContainer = root; + if (string.Equals(eventType, "response.failed", StringComparison.Ordinal) + && root.TryGetProperty("response", out var failedResponse) + && failedResponse.ValueKind == JsonValueKind.Object) + { + errorContainer = failedResponse; + } + else if (!string.Equals(eventType, "error", StringComparison.Ordinal)) + { + return; + } + + string? code = null; + string? message = null; + if (errorContainer.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) + { + code = StringProperty(error, "code") ?? StringProperty(error, "type"); + message = StringProperty(error, "message"); + } + + code ??= StringProperty(errorContainer, "code") ?? "unknown"; + message ??= StringProperty(errorContainer, "message") ?? "The WebSocket service returned an error."; + throw new OpenAIWebSocketProtocolException(code, message); + } + + private static bool CanFallbackToServerSentEvents(Exception exception) + { + if (exception is OpenAIWebSocketProtocolException protocol) + { + return string.Equals( + protocol.Code, + "websocket_connection_limit_reached", + StringComparison.OrdinalIgnoreCase); + } + + return exception is not InvalidDataException + && exception is not JsonException + && exception is not ArgumentException + && exception is not ModelProviderException; + } + + private static string? StringProperty(JsonElement value, string name) => + value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private bool IsWebSocketFallbackActive(string sessionId) + { + lock (_webSocketGate) + { + return _webSocketFallbackSessions.Contains(sessionId); + } + } + + private void RecordWebSocketRequest(string? sessionId, bool reused, bool delta) + { + if (sessionId is null) + { + return; + } + + lock (_webSocketGate) + { + var statistics = GetOrCreateWebSocketStatistics(sessionId); + statistics.Requests++; + if (reused) + { + statistics.ConnectionsReused++; + } + else + { + statistics.ConnectionsCreated++; + } + + if (delta) + { + statistics.DeltaRequests++; + } + else + { + statistics.FullContextRequests++; + } + } + } + + private void RecordWebSocketFailure(string? sessionId, Exception exception) + { + if (sessionId is null) + { + return; + } + + lock (_webSocketGate) + { + _webSocketFallbackSessions.Add(sessionId); + var statistics = GetOrCreateWebSocketStatistics(sessionId); + statistics.Failures++; + statistics.LastError = BoundExceptionMessage(exception); + } + } + + private void RecordWebSocketSseFallback(string? sessionId) + { + if (sessionId is null) + { + return; + } + + lock (_webSocketGate) + { + GetOrCreateWebSocketStatistics(sessionId).SseFallbacks++; + } + } + + private MutableWebSocketStatistics GetOrCreateWebSocketStatistics(string sessionId) + { + if (!_webSocketStatistics.TryGetValue(sessionId, out var value)) + { + value = new MutableWebSocketStatistics(); + _webSocketStatistics[sessionId] = value; + } + + return value; + } + + private static ModelDiagnostic TransportDiagnostic(ModelTransport transport, string error) => + new( + "provider_transport_fallback", + "The WebSocket request failed before response output began; the request continued over server-sent events.", + ModelDiagnosticSeverity.Warning, + JsonSerializer.Serialize(new + { + configuredTransport = transport.ToString(), + fallbackTransport = ModelTransport.ServerSentEvents.ToString(), + phase = "before_response_output", + error, + })); + + private static string BoundExceptionMessage(Exception exception) + { + var message = string.IsNullOrWhiteSpace(exception.Message) + ? exception.GetType().Name + : exception.Message; + return message.Length <= 4096 ? message : message.Substring(0, 4096); + } + + private static async ValueTask DisposeIgnoringFailureAsync(IAsyncDisposable value) + { + try + { + await value.DisposeAsync().ConfigureAwait(false); + } + catch + { + } + } + + private static async Task AwaitWithCancellationAsync( + Task operation, + CancellationToken cancellationToken) + { + if (operation.IsCompleted || !cancellationToken.CanBeCanceled) + { + await operation.ConfigureAwait(false); + return; + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + () => canceled.TrySetResult(true)); + if (await Task.WhenAny(operation, canceled.Task).ConfigureAwait(false) != operation) + { + ObserveLateCompletion(operation); + cancellationToken.ThrowIfCancellationRequested(); + } + + await operation.ConfigureAwait(false); + } + + private static async Task AwaitWithCancellationAsync( + Task operation, + CancellationToken cancellationToken, + Action? lateSuccess = null) + { + if (operation.IsCompleted || !cancellationToken.CanBeCanceled) + { + return await operation.ConfigureAwait(false); + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + () => canceled.TrySetResult(true)); + if (await Task.WhenAny(operation, canceled.Task).ConfigureAwait(false) != operation) + { + ObserveLateCompletion(operation, lateSuccess); + cancellationToken.ThrowIfCancellationRequested(); + } + + return await operation.ConfigureAwait(false); + } + + private static void ObserveLateCompletion(Task operation) + { + _ = operation.ContinueWith( + completed => + { + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static void ObserveLateCompletion(Task operation, Action? lateSuccess) + { + _ = operation.ContinueWith( + completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + try + { + lateSuccess?.Invoke(completed.Result); + } + catch + { + } + } + else if (completed.IsFaulted) + { + _ = completed.Exception; + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(OpenAIResponsesProvider)); + } + } + + private static void ValidateOptions(OpenAIResponsesProviderOptions options) + { + if (!options.Endpoint.IsAbsoluteUri + || options.Endpoint.UserInfo.Length > 0 + || (options.Endpoint.Scheme != Uri.UriSchemeHttp && options.Endpoint.Scheme != Uri.UriSchemeHttps)) + { + throw new ArgumentException("The endpoint must be an absolute HTTP or HTTPS URI without embedded credentials.", nameof(options)); + } + + if (options.Endpoint.Scheme == Uri.UriSchemeHttp && !options.Endpoint.IsLoopback && !options.AllowInsecureHttp) + { + throw new ArgumentException("Remote endpoints must use HTTPS unless insecure HTTP is explicitly enabled.", nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ProviderId) + || string.IsNullOrWhiteSpace(options.ApiId) + || options.ProviderId.Length > 256 + || options.ApiId.Length > 256) + { + throw new ArgumentException("Provider and API identifiers must contain 1 to 256 characters.", nameof(options)); + } + + if (!Enum.IsDefined(typeof(OpenAISessionAffinityFormat), options.SessionAffinityFormat) + || !Enum.IsDefined(typeof(OpenAIAuthenticationStyle), options.AuthenticationStyle) + || !Enum.IsDefined(typeof(OpenAISystemPromptMode), options.SystemPromptMode) + || options.TextVerbosity is { } verbosity && !Enum.IsDefined(typeof(OpenAITextVerbosity), verbosity) + || options.ToolChoice is { } toolChoice && !Enum.IsDefined(typeof(OpenAIToolChoice), toolChoice) + || options.MaxEventCharacters is < 1 or > 100_000_000 + || options.MaxErrorCharacters is < 1 or > 10_000_000 + || options.MaxRequestBytes is < 2 or > 100_000_000 + || options.MaxResponseCharacters is < 1 or > 100_000_000 + || options.MaxToolCallsPerResponse is < 1 or > 10_000 + || options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000 + || options.WebSocketIdleTimeoutMilliseconds is < 1 or > 86_400_000 + || options.WebSocketSessionIdleTimeoutMilliseconds is < 1 or > 86_400_000 + || options.WebSocketMaximumConnectionAgeMilliseconds is < 1 or > 86_400_000) + { + throw new ArgumentException("One or more provider bounds or compatibility settings are invalid.", nameof(options)); + } + + if (!options.SupportsWebSocketTransport && options.WebSocketConnectionFactory is not null) + { + throw new ArgumentException( + "A WebSocket connection factory requires WebSocket transport support to be enabled.", + nameof(options)); + } + + ValidateCredential(options.ApiKey, nameof(options)); + if (ProviderHeaderGuard.IsTransportControlledHeader(options.ApiKeyHeaderName)) + { + throw new ArgumentException("The API key header is controlled by the transport.", nameof(options)); + } + + ValidateHeader(options.ApiKeyHeaderName, "placeholder", nameof(options)); + if (options.DefaultInstructions is null + || options.DefaultInstructions.Length > options.MaxRequestBytes + || (options.ReasoningSummary?.Length ?? 0) > 64 + || (options.ServiceTier?.Length ?? 0) > 64) + { + throw new ArgumentException("One or more Responses request defaults are invalid.", nameof(options)); + } + + if (options.GetCredentialAsync is not null && options.GetApiKeyAsync is not null) + { + throw new ArgumentException("Configure either a credential provider or an API-key provider, not both.", nameof(options)); + } + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); + } + + private void ApplyHeaders( + HttpRequestMessage request, + OpenAIRequestCredential credential, + ModelRequest modelRequest) + { + var apiKey = credential.ApiKey; + ValidateCredential(apiKey, nameof(OpenAIResponsesProviderOptions.GetApiKeyAsync)); + ProviderHeaderGuard.ValidateMerge( + credential.Headers, + nameof(OpenAIResponsesProviderOptions.GetCredentialAsync)); + var suppressedHeaders = new HashSet(StringComparer.OrdinalIgnoreCase); + ApplyHeaderLayer(request, _headers, suppressedHeaders, "provider"); + ApplyHeaderLayer(request, credential.Headers, suppressedHeaders, "credential"); + + var credentialHeader = _authenticationStyle == OpenAIAuthenticationStyle.Bearer + ? "Authorization" + : _apiKeyHeaderName; + var credentialValue = _authenticationStyle == OpenAIAuthenticationStyle.Bearer + ? "Bearer " + apiKey + : apiKey; + if (_authenticationStyle != OpenAIAuthenticationStyle.None + && !string.IsNullOrEmpty(apiKey) + && !request.Headers.Contains(credentialHeader) + && !request.Headers.TryAddWithoutValidation(credentialHeader, credentialValue)) + { + throw new InvalidOperationException("The authorization header could not be applied."); + } + + if (modelRequest.Parameters.CacheRetention == ModelCacheRetention.None + || string.IsNullOrEmpty(modelRequest.SessionId)) + { + return; + } + + var sessionId = modelRequest.SessionId!; + var affinityHeaders = _sessionAffinityFormat switch + { + OpenAISessionAffinityFormat.OpenRouter => new[] { ("x-session-id", sessionId) }, + OpenAISessionAffinityFormat.OpenAIWithoutSessionHeader => new[] { ("x-client-request-id", sessionId) }, + OpenAISessionAffinityFormat.Codex => new[] { ("session-id", sessionId), ("x-client-request-id", sessionId) }, + _ => new[] { ("session_id", sessionId), ("x-client-request-id", sessionId) }, + }; + foreach (var header in affinityHeaders) + { + if (!suppressedHeaders.Contains(header.Item1) + && !request.Headers.Contains(header.Item1) + && !request.Headers.TryAddWithoutValidation(header.Item1, header.Item2)) + { + throw new InvalidOperationException($"Session header '{header.Item1}' is not valid for an HTTP request."); + } + } + } + + private static void ApplyHeaderLayer( + HttpRequestMessage request, + IReadOnlyDictionary headers, + ISet suppressedHeaders, + string layer) + { + foreach (var header in headers) + { + request.Headers.Remove(header.Key); + if (header.Value is null) + { + suppressedHeaders.Add(header.Key); + continue; + } + + suppressedHeaders.Remove(header.Key); + if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + throw new InvalidOperationException( + $"The {layer} header '{header.Key}' is not valid for an HTTP request."); + } + } + } + + private byte[] SerializeRequest(ModelRequest request) + { + var normalizedMessages = ProviderTranscript.Normalize( + request.Messages, + _providerId, + _apiId, + request.Model, + (id, _, _, _) => + { + var identity = NormalizeToolIdentity(id, sameProtocol: false, sameModel: false); + return identity.CallId + "|" + identity.ItemId; + }); + var toolPlacement = SplitTools(request, normalizedMessages); + var payload = new Dictionary + { + ["model"] = request.Model, + ["input"] = ProjectInput( + request, + normalizedMessages, + toolPlacement.Deferred, + includeSystemPrompt: _systemPromptMode == OpenAISystemPromptMode.InputMessage), + ["stream"] = true, + ["store"] = false, + }; + if (_systemPromptMode == OpenAISystemPromptMode.Instructions) + { + payload["instructions"] = request.SystemPrompt.Length == 0 + ? _defaultInstructions + : request.SystemPrompt; + } + + if (_serviceTier is not null) + { + payload["service_tier"] = _serviceTier; + } + + if (_textVerbosity is { } verbosity) + { + payload["text"] = new Dictionary + { + ["verbosity"] = verbosity.ToString().ToLowerInvariant(), + }; + } + + if (_toolChoice is { } toolChoice) + { + payload["tool_choice"] = toolChoice.ToString().ToLowerInvariant(); + } + + if (_parallelToolCalls is { } parallelToolCalls) + { + payload["parallel_tool_calls"] = parallelToolCalls; + } + if (request.Parameters.MaxOutputTokens is { } maximum) + { + payload["max_output_tokens"] = Math.Max(MinimumOutputTokens, maximum); + } + + if (request.Parameters.Temperature is { } temperature) + { + payload["temperature"] = temperature; + } + + if (request.Parameters.CacheRetention != ModelCacheRetention.None && request.SessionId is { } sessionId) + { + payload["prompt_cache_key"] = ClampUnicode(sessionId, 64); + } + + if (request.Parameters.CacheRetention == ModelCacheRetention.Long && _supportsLongCacheRetention) + { + payload["prompt_cache_retention"] = "24h"; + } + + if (request.Parameters.CacheRetention == ModelCacheRetention.None && _supportsExplicitPromptCacheMode) + { + payload["prompt_cache_options"] = new Dictionary { ["mode"] = "explicit" }; + } + + if (toolPlacement.Immediate.Count > 0) + { + payload["tools"] = ProjectTools(toolPlacement.Immediate, deferLoading: false); + } + + if (!string.IsNullOrWhiteSpace(request.Parameters.ReasoningLevel)) + { + payload["reasoning"] = new Dictionary + { + ["effort"] = request.Parameters.ReasoningLevel, + ["summary"] = _reasoningSummary ?? "auto", + }; + payload["include"] = new[] { "reasoning.encrypted_content" }; + } + else if (_alwaysIncludeEncryptedReasoning) + { + payload["include"] = new[] { "reasoning.encrypted_content" }; + } + + foreach (var extension in request.Parameters.Extensions) + { + if (payload.ContainsKey(extension.Key)) + { + throw new InvalidOperationException($"Model extension '{extension.Key}' cannot override a core request field."); + } + + payload[extension.Key] = ParseJsonOrString(extension.Value); + } + + if (request.Parameters.SamplingParametersJson is { } sampling) + { + using var document = JsonDocument.Parse(sampling); + foreach (var property in document.RootElement.EnumerateObject()) + { + payload[property.Name] = property.Value.Clone(); + } + } + + var body = JsonSerializer.SerializeToUtf8Bytes(payload); + if (body.Length > _maxRequestBytes) + { + throw new InvalidDataException("The Responses request exceeded the configured byte limit."); + } + + return body; + } + + private IReadOnlyList ProjectInput( + ModelRequest request, + IReadOnlyList messages, + IReadOnlyDictionary deferredTools, + bool includeSystemPrompt) + { + var input = new List(); + if (includeSystemPrompt && request.SystemPrompt.Length > 0) + { + input.Add(new Dictionary + { + ["role"] = _supportsDeveloperRole ? "developer" : "system", + ["content"] = request.SystemPrompt, + }); + } + + var grammarProperties = GrammarInputProperties(request.Tools); + var loadedTools = new HashSet(StringComparer.Ordinal); + for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) + { + var message = messages[messageIndex]; + if (message.Role is AgentRole.User or AgentRole.Custom) + { + var content = ProjectUserContent(message); + if (content.Count > 0) + { + input.Add(new Dictionary + { + ["role"] = "user", + ["content"] = content, + }); + } + + continue; + } + + if (message.Role == AgentRole.Assistant) + { + var sameProtocol = string.Equals(message.Provider, _providerId, StringComparison.Ordinal) + && string.Equals(message.Api, _apiId, StringComparison.Ordinal); + var sameModel = sameProtocol && string.Equals(message.Model, request.Model, StringComparison.Ordinal); + var textIndex = 0; + foreach (var content in message.Content) + { + switch (content) + { + case ReasoningContent reasoning when sameProtocol && !string.IsNullOrWhiteSpace(reasoning.Signature): + input.Add(ParseRequiredObject(reasoning.Signature!, "A reasoning signature must contain a JSON object.")); + break; + case TextContent text: + var textIdentity = ParseTextIdentity(text.Signature); + var messageId = textIdentity.Id ?? $"msg_oga_{messageIndex}_{textIndex}"; + textIndex++; + if (messageId.Length > 64) + { + messageId = "msg_" + ShortHash(messageId); + } + + var outputMessage = new Dictionary + { + ["type"] = "message", + ["role"] = "assistant", + ["content"] = new object[] + { + new Dictionary + { + ["type"] = "output_text", + ["text"] = text.Text, + ["annotations"] = Array.Empty(), + }, + }, + ["status"] = "completed", + ["id"] = messageId, + }; + if (textIdentity.Phase is { } phase) + { + outputMessage["phase"] = phase; + } + + input.Add(outputMessage); + break; + case ToolCallContent call: + var identity = NormalizeToolIdentity(call.Id, sameProtocol, sameModel); + var canReplayNamespace = sameModel || deferredTools.ContainsKey(call.Name); + if (grammarProperties.TryGetValue(call.Name, out var property)) + { + using var arguments = JsonDocument.Parse(call.ArgumentsJson); + if (!arguments.RootElement.TryGetProperty(property, out var grammarInput) + || grammarInput.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException( + $"Grammar tool call '{call.Name}' requires string argument '{property}'."); + } + + var customCall = new Dictionary + { + ["type"] = "custom_tool_call", + ["call_id"] = identity.CallId, + ["name"] = call.Name, + ["input"] = grammarInput.GetString(), + }; + if (identity.ItemId is not null) + { + customCall["id"] = identity.ItemId; + } + + if (canReplayNamespace && call.Namespace is not null) + { + customCall["namespace"] = call.Namespace; + } + + input.Add(customCall); + } + else + { + var functionCall = new Dictionary + { + ["type"] = "function_call", + ["call_id"] = identity.CallId, + ["name"] = call.Name, + ["arguments"] = call.ArgumentsJson, + }; + if (identity.ItemId is not null) + { + functionCall["id"] = identity.ItemId; + } + + if (canReplayNamespace && call.Namespace is not null) + { + functionCall["namespace"] = call.Namespace; + } + + input.Add(functionCall); + } + + break; + } + } + + continue; + } + + if (message.Role == AgentRole.Tool) + { + var callId = message.ToolCallId!.Split('|')[0]; + input.Add(new Dictionary + { + ["type"] = grammarProperties.ContainsKey(message.ToolName!) + ? "custom_tool_call_output" + : "function_call_output", + ["call_id"] = callId, + ["output"] = ProjectToolResultOutput(message.Content), + }); + + var additions = message.AddedToolNames + .Where(name => deferredTools.ContainsKey(name) && loadedTools.Add(name)) + .Select(name => deferredTools[name]) + .ToArray(); + if (additions.Length > 0 && _supportsAdditionalTools) + { + input.Add(new Dictionary + { + ["type"] = "additional_tools", + ["role"] = "developer", + ["tools"] = ProjectTools(additions, deferLoading: false), + }); + } + else if (additions.Length > 0 && _supportsToolSearch) + { + var names = additions.Select(tool => tool.Name).ToArray(); + var searchCallId = "oga_tool_load_" + ShortHash(message.ToolCallId + ":" + string.Join(",", names)); + input.Add(new Dictionary + { + ["type"] = "tool_search_call", + ["call_id"] = searchCallId, + ["execution"] = "client", + ["status"] = "completed", + ["arguments"] = new Dictionary + { + ["query"] = string.Join(" ", names), + ["limit"] = names.Length, + }, + }); + input.Add(new Dictionary + { + ["type"] = "tool_search_output", + ["call_id"] = searchCallId, + ["execution"] = "client", + ["status"] = "completed", + ["tools"] = ProjectTools(additions, deferLoading: true), + }); + } + } + } + + return input; + } + + private static IReadOnlyList ProjectUserContent(AgentMessage message) + { + var parts = new List(); + if (message.Role == AgentRole.Custom) + { + parts.Add(new Dictionary + { + ["type"] = "input_text", + ["text"] = "[" + message.CustomRole + "]", + }); + } + + foreach (var content in message.Content) + { + switch (content) + { + case TextContent text: + parts.Add(new Dictionary { ["type"] = "input_text", ["text"] = text.Text }); + break; + case JsonContent json: + parts.Add(new Dictionary { ["type"] = "input_text", ["text"] = json.Json }); + break; + case BinaryContent binary when binary.MediaKind == AgentMediaKind.Image + || binary.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + parts.Add(new Dictionary + { + ["type"] = "input_image", + ["detail"] = "auto", + ["image_url"] = $"data:{binary.MediaType};base64,{binary.Data}", + }); + break; + case ResourceContent resource when resource.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + parts.Add(new Dictionary + { + ["type"] = "input_image", + ["detail"] = "auto", + ["image_url"] = resource.Uri, + }); + break; + case ResourceContent resource: + parts.Add(new Dictionary + { + ["type"] = "input_text", + ["text"] = $"[resource media_type={resource.MediaType}] {resource.Uri}", + }); + break; + case BinaryContent binary: + parts.Add(new Dictionary + { + ["type"] = "input_text", + ["text"] = $"[binary media_type={binary.MediaType} data_omitted]", + }); + break; + } + } + + return parts; + } + + private static object ProjectToolResultOutput(IEnumerable content) + { + var parts = new List(); + var text = new List(); + foreach (var item in content) + { + switch (item) + { + case TextContent value: + text.Add(value.Text); + break; + case JsonContent value: + text.Add(value.Json); + break; + case ResourceContent value when value.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + parts.Add(new Dictionary + { + ["type"] = "input_image", + ["detail"] = "auto", + ["image_url"] = value.Uri, + }); + break; + case BinaryContent value when value.MediaKind == AgentMediaKind.Image + || value.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase): + parts.Add(new Dictionary + { + ["type"] = "input_image", + ["detail"] = "auto", + ["image_url"] = $"data:{value.MediaType};base64,{value.Data}", + }); + break; + } + } + + if (parts.Count == 0) + { + return text.Count > 0 ? string.Join("\n", text) : "(no tool output)"; + } + + parts.Insert(0, new Dictionary + { + ["type"] = "input_text", + ["text"] = text.Count > 0 ? string.Join("\n", text) : "(see attached image)", + }); + return parts; + } + + private object[] ProjectTools(IEnumerable tools, bool deferLoading) + { + return tools.Select(tool => ProjectTool(tool, deferLoading)).ToArray(); + } + + private object ProjectTool(ToolDefinition tool, bool deferLoading) + { + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar && _supportsGrammarTools) + { + _ = InferGrammarInputProperty(tool); + var syntax = !string.IsNullOrWhiteSpace(tool.ConstrainedSampling.OpenAiLark) ? "lark" : "regex"; + var definition = syntax == "lark" + ? tool.ConstrainedSampling.OpenAiLark + : tool.ConstrainedSampling.OpenAiRegex; + var custom = new Dictionary + { + ["type"] = "custom", + ["name"] = tool.Name, + ["description"] = tool.Description, + ["format"] = new Dictionary + { + ["type"] = "grammar", + ["syntax"] = syntax, + ["definition"] = definition, + }, + }; + if (deferLoading) + { + custom["defer_loading"] = true; + } + + return custom; + } + + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema + && tool.ConstrainedSampling.Strictness == ToolSchemaStrictness.Require + && !_supportsStrictTools) + { + throw new InvalidOperationException( + $"Tool '{tool.Name}' requires strict JSON-schema sampling, but the endpoint does not support it."); + } + + var function = new Dictionary + { + ["type"] = "function", + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = ParseRequiredObject(tool.InputSchemaJson, "A tool schema must be a JSON object."), + }; + if (_supportsStrictTools) + { + function["strict"] = tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema; + } + + if (deferLoading) + { + function["defer_loading"] = true; + } + + return function; + } + + private static JsonElement ParseRequiredObject(string json, string message) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException(message); + } + + return document.RootElement.Clone(); + } + + private static (string? Id, string? Phase) ParseTextIdentity(string? signature) + { + if (string.IsNullOrEmpty(signature)) + { + return (null, null); + } + + if (signature.StartsWith("{", StringComparison.Ordinal)) + { + try + { + using var document = JsonDocument.Parse(signature); + var root = document.RootElement; + if (root.TryGetProperty("v", out var version) + && version.TryGetInt32(out var parsedVersion) + && parsedVersion == 1 + && root.TryGetProperty("id", out var id) + && id.ValueKind == JsonValueKind.String) + { + var phase = root.TryGetProperty("phase", out var phaseElement) + && phaseElement.ValueKind == JsonValueKind.String + ? phaseElement.GetString() + : null; + return (id.GetString(), phase is "commentary" or "final_answer" ? phase : null); + } + } + catch (JsonException) + { + } + } + + return (signature, null); + } + + private static (string CallId, string? ItemId) NormalizeToolIdentity( + string id, + bool sameProtocol, + bool sameModel) + { + var split = id.Split('|'); + var callId = NormalizeId(split[0], "call"); + var rawItemId = split.Length > 1 ? split[1] : null; + var itemId = rawItemId is null ? null : NormalizeId(rawItemId, "fc"); + if (!sameProtocol && rawItemId?.StartsWith("fc_", StringComparison.Ordinal) != true) + { + itemId = "fc_" + ShortHash(id); + } + else if (!sameModel && itemId?.StartsWith("fc_", StringComparison.Ordinal) == true) + { + itemId = null; + } + else if (itemId is not null && !itemId.StartsWith("fc_", StringComparison.Ordinal)) + { + itemId = "fc_" + itemId; + } + + return (callId, itemId); + } + + private static string NormalizeId(string value, string prefix) + { + if (string.IsNullOrWhiteSpace(value)) + { + return prefix + "_" + ShortHash(value ?? string.Empty); + } + + var valid = value.All(character => char.IsLetterOrDigit(character) || character is '_' or '-'); + var normalized = valid ? value : prefix + "_" + ShortHash(value); + return normalized.Length <= 64 ? normalized : prefix + "_" + ShortHash(normalized); + } + + private ToolPlacement SplitTools(ModelRequest request, IReadOnlyList messages) + { + var supportsDeferred = _supportsAdditionalTools || _supportsToolSearch; + if (!supportsDeferred) + { + return new ToolPlacement(request.Tools, new Dictionary(StringComparer.Ordinal)); + } + + var used = new HashSet(StringComparer.Ordinal); + var deferredNames = new HashSet(StringComparer.Ordinal); + foreach (var message in messages) + { + if (message.Role == AgentRole.Assistant) + { + foreach (var call in message.Content.OfType()) + { + used.Add(call.Name); + } + } + else if (message.Role == AgentRole.Tool) + { + foreach (var name in message.AddedToolNames) + { + if (!used.Contains(name)) + { + deferredNames.Add(name); + } + } + } + } + + var unique = request.Tools.GroupBy(tool => tool.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); + var deferred = unique.Where(pair => deferredNames.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var immediate = unique.Where(pair => !deferredNames.Contains(pair.Key)).Select(pair => pair.Value).ToArray(); + return new ToolPlacement(immediate, deferred); + } + + private sealed class ToolPlacement + { + public ToolPlacement( + IReadOnlyList immediate, + IReadOnlyDictionary deferred) + { + Immediate = immediate; + Deferred = deferred; + } + + public IReadOnlyList Immediate { get; } + + public IReadOnlyDictionary Deferred { get; } + } + + private static object? ParseJsonOrString(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return value; + } + } + + private static string ClampUnicode(string value, int maximumCharacters) + { + if (value.Length <= maximumCharacters) + { + return value; + } + + return value.Substring(0, maximumCharacters); + } + + private static void ValidateCredential(string? value, string parameterName) + { + if ((value?.Length ?? 0) > 65_536 + || (value is { Length: > 0 } && string.IsNullOrWhiteSpace(value)) + || value?.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A credential is empty, too large, or contains invalid control characters.", parameterName); + } + } + + private static void ValidateHeader(string name, string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(name) + || name.Length > 256 + || value is null + || value.Length > 65_536 + || name.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("HTTP headers are empty, too large, or contain invalid control characters.", parameterName); + } + } + + private static async Task ReadBoundedAsync( + HttpContent content, + int maximumCharacters, + CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); + var buffer = new char[Math.Min(4096, maximumCharacters)]; + var builder = new StringBuilder(); + while (builder.Length < maximumCharacters) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await reader.ReadAsync(buffer, 0, Math.Min(buffer.Length, maximumCharacters - builder.Length)) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + builder.Append(buffer, 0, read); + } + + return builder.ToString(); + } + + private static async IAsyncEnumerable ReadBoundedLinesAsync( + StreamReader reader, + int maximumCharacters, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(Math.Min(4096, maximumCharacters + 1)); + var line = new StringBuilder(); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + int read; + try + { + read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + if (read == 0) + { + if (line.Length > 0) + { + yield return TrimCarriageReturn(line); + } + + yield break; + } + + for (var index = 0; index < read; index++) + { + if (buffer[index] == '\n') + { + yield return TrimCarriageReturn(line); + line.Clear(); + } + else + { + line.Append(buffer[index]); + if (line.Length > maximumCharacters) + { + throw new InvalidDataException("A Responses stream event exceeded the configured size limit."); + } + } + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string TrimCarriageReturn(StringBuilder line) + { + var length = line.Length; + if (length > 0 && line[length - 1] == '\r') + { + length--; + } + + return line.ToString(0, length); + } + + private static string ShortHash(string value) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); + var builder = new StringBuilder(16); + for (var index = 0; index < 8; index++) + { + builder.Append(bytes[index].ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + private IReadOnlyDictionary GrammarInputProperties(IEnumerable tools) + { + return tools.Where(tool => tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar) + .ToDictionary(tool => tool.Name, InferGrammarInputProperty, StringComparer.Ordinal); + } + + private static string InferGrammarInputProperty(ToolDefinition tool) + { + using var document = JsonDocument.Parse(tool.InputSchemaJson); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var type) + || type.GetString() != "object" + || !root.TryGetProperty("required", out var required) + || required.ValueKind != JsonValueKind.Array + || required.GetArrayLength() != 1 + || required[0].ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Grammar tool '{tool.Name}' requires an object schema with exactly one required string property."); + } + + var property = required[0].GetString()!; + if (!root.TryGetProperty("properties", out var properties) + || !properties.TryGetProperty(property, out var schema) + || !schema.TryGetProperty("type", out var propertyType) + || propertyType.GetString() != "string") + { + throw new InvalidOperationException( + $"Grammar tool '{tool.Name}' requires its sole required property to be a string."); + } + + return property; + } + + private sealed class OpenAIWebSocketProtocolException : IOException + { + public OpenAIWebSocketProtocolException(string code, string message) + : base($"WebSocket protocol error {code}: {message}") + { + Code = code; + } + + public string Code { get; } + } + + private sealed class WebSocketLease + { + private readonly Action _release; + private int _released; + + public WebSocketLease( + IOpenAIWebSocketConnection connection, + CachedWebSocketConnection? entry, + bool reused, + Action release) + { + Connection = connection; + Entry = entry; + Reused = reused; + _release = release; + } + + public IOpenAIWebSocketConnection Connection { get; } + + public CachedWebSocketConnection? Entry { get; } + + public bool Reused { get; } + + public void Release(bool keep) + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + _release(keep); + } + } + } + + private sealed class CachedWebSocketConnection : IDisposable + { + private int _disposed; + + public CachedWebSocketConnection(IOpenAIWebSocketConnection connection) + { + Connection = connection; + CreatedAt = DateTimeOffset.UtcNow; + Busy = true; + } + + public IOpenAIWebSocketConnection Connection { get; } + + public DateTimeOffset CreatedAt { get; } + + public bool Busy { get; set; } + + public Timer? IdleTimer { get; set; } + + public WebSocketContinuation? Continuation { get; set; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + IdleTimer?.Dispose(); + Connection.Dispose(); + } + } + + private sealed class WebSocketContinuation + { + public WebSocketContinuation(string fingerprint, string responseId, IReadOnlyList baselineItems) + { + Fingerprint = fingerprint; + ResponseId = responseId; + BaselineItems = baselineItems; + } + + public string Fingerprint { get; } + + public string ResponseId { get; } + + public IReadOnlyList BaselineItems { get; } + } + + private sealed class RequestBodyDelta + { + public RequestBodyDelta(string responseId, IReadOnlyList items) + { + ResponseId = responseId; + Items = items; + } + + public string ResponseId { get; } + + public IReadOnlyList Items { get; } + } + + private sealed class RequestBodySnapshot + { + private readonly byte[] _body; + + private RequestBodySnapshot(byte[] body, string fingerprint, IReadOnlyList inputItems) + { + _body = body; + Fingerprint = fingerprint; + InputItems = inputItems; + } + + public string Fingerprint { get; } + + public IReadOnlyList InputItems { get; } + + public static RequestBodySnapshot Create(byte[] body) + { + using var document = JsonDocument.Parse(body, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("input", out var input) + || input.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("The Responses request did not contain an input array."); + } + + var items = input.EnumerateArray().Select(item => item.GetRawText()).ToArray(); + using var canonical = new MemoryStream(); + using (var writer = new Utf8JsonWriter(canonical)) + { + writer.WriteStartObject(); + foreach (var property in root.EnumerateObject()) + { + if (property.NameEquals("input") || property.NameEquals("previous_response_id")) + { + continue; + } + + property.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + using var sha = SHA256.Create(); + var fingerprint = Convert.ToBase64String(sha.ComputeHash(canonical.ToArray())); + return new RequestBodySnapshot(body.ToArray(), fingerprint, items); + } + + public RequestBodyDelta? TryCreateDelta(WebSocketContinuation continuation) + { + if (!string.Equals(Fingerprint, continuation.Fingerprint, StringComparison.Ordinal) + || InputItems.Count < continuation.BaselineItems.Count) + { + return null; + } + + for (var index = 0; index < continuation.BaselineItems.Count; index++) + { + if (!string.Equals( + InputItems[index], + continuation.BaselineItems[index], + StringComparison.Ordinal)) + { + return null; + } + } + + return new RequestBodyDelta( + continuation.ResponseId, + InputItems.Skip(continuation.BaselineItems.Count).ToArray()); + } + + public string CreateWebSocketRequest(RequestBodyDelta? delta) + { + using var document = JsonDocument.Parse(_body, new JsonDocumentOptions { MaxDepth = 128 }); + using var output = new MemoryStream(); + using (var writer = new Utf8JsonWriter(output)) + { + writer.WriteStartObject(); + writer.WriteString("type", "response.create"); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.NameEquals("previous_response_id")) + { + continue; + } + + if (delta is not null && property.NameEquals("input")) + { + writer.WritePropertyName("input"); + writer.WriteStartArray(); + foreach (var item in delta.Items) + { + using var itemDocument = JsonDocument.Parse(item, new JsonDocumentOptions { MaxDepth = 128 }); + itemDocument.RootElement.WriteTo(writer); + } + + writer.WriteEndArray(); + } + else + { + property.WriteTo(writer); + } + } + + if (delta is not null) + { + writer.WriteString("previous_response_id", delta.ResponseId); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(output.ToArray()); + } + } + + private sealed class MutableWebSocketStatistics + { + public long Requests { get; set; } + + public long ConnectionsCreated { get; set; } + + public long ConnectionsReused { get; set; } + + public long FullContextRequests { get; set; } + + public long DeltaRequests { get; set; } + + public long Failures { get; set; } + + public long SseFallbacks { get; set; } + + public string? LastError { get; set; } + + public OpenAIWebSocketStatistics Snapshot(bool fallbackActive) => + new( + Requests, + ConnectionsCreated, + ConnectionsReused, + FullContextRequests, + DeltaRequests, + Failures, + SseFallbacks, + fallbackActive, + LastError); + } +} diff --git a/src/OpenGameAgent.Providers.OpenAI/OpenAIWebSocketTransport.cs b/src/OpenGameAgent.Providers.OpenAI/OpenAIWebSocketTransport.cs new file mode 100644 index 0000000..144a148 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/OpenAIWebSocketTransport.cs @@ -0,0 +1,307 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Net.WebSockets; +using System.Text; + +namespace OpenGameAgent.Providers.OpenAI; + +public sealed class OpenAIWebSocketConnectRequest +{ + public OpenAIWebSocketConnectRequest( + Uri endpoint, + IReadOnlyDictionary headers, + int? timeoutMilliseconds = null) + { + if (endpoint is null + || !endpoint.IsAbsoluteUri + || endpoint.UserInfo.Length > 0 + || (endpoint.Scheme != "ws" && endpoint.Scheme != "wss")) + { + throw new ArgumentException( + "The WebSocket endpoint must be an absolute ws or wss URI without embedded credentials.", + nameof(endpoint)); + } + + if (timeoutMilliseconds is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + } + + Endpoint = endpoint; + Headers = new ReadOnlyDictionary( + new Dictionary( + headers ?? throw new ArgumentNullException(nameof(headers)), + StringComparer.OrdinalIgnoreCase)); + TimeoutMilliseconds = timeoutMilliseconds; + } + + public Uri Endpoint { get; } + + public IReadOnlyDictionary Headers { get; } + + public int? TimeoutMilliseconds { get; } +} + +public interface IOpenAIWebSocketConnection : IDisposable +{ + bool IsOpen { get; } + + ValueTask SendTextAsync(string text, CancellationToken cancellationToken); + + ValueTask ReceiveTextAsync(int maximumCharacters, CancellationToken cancellationToken); + + ValueTask CloseAsync(string reason, CancellationToken cancellationToken); +} + +public interface IOpenAIWebSocketResponseMetadata +{ + int HandshakeStatusCode { get; } + + IReadOnlyDictionary HandshakeHeaders { get; } +} + +public delegate ValueTask OpenAIWebSocketConnectionFactory( + OpenAIWebSocketConnectRequest request, + CancellationToken cancellationToken); + +public sealed class OpenAIWebSocketStatistics +{ + internal OpenAIWebSocketStatistics( + long requests, + long connectionsCreated, + long connectionsReused, + long fullContextRequests, + long deltaRequests, + long failures, + long sseFallbacks, + bool fallbackActive, + string? lastError) + { + Requests = requests; + ConnectionsCreated = connectionsCreated; + ConnectionsReused = connectionsReused; + FullContextRequests = fullContextRequests; + DeltaRequests = deltaRequests; + Failures = failures; + SseFallbacks = sseFallbacks; + FallbackActive = fallbackActive; + LastError = lastError; + } + + public long Requests { get; } + + public long ConnectionsCreated { get; } + + public long ConnectionsReused { get; } + + public long FullContextRequests { get; } + + public long DeltaRequests { get; } + + public long Failures { get; } + + public long SseFallbacks { get; } + + public bool FallbackActive { get; } + + public string? LastError { get; } +} + +internal sealed class ClientOpenAIWebSocketConnection : + IOpenAIWebSocketConnection, + IOpenAIWebSocketResponseMetadata +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private readonly ClientWebSocket _socket; + private bool _disposed; + + private ClientOpenAIWebSocketConnection(ClientWebSocket socket) + { + _socket = socket; + } + + public bool IsOpen => !_disposed && _socket.State == WebSocketState.Open; + + public int HandshakeStatusCode => 101; + + public IReadOnlyDictionary HandshakeHeaders { get; } = + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public static async ValueTask ConnectAsync( + OpenAIWebSocketConnectRequest request, + CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var socket = new ClientWebSocket(); + try + { + foreach (var header in request.Headers) + { + socket.Options.SetRequestHeader(header.Key, header.Value); + } + + using var timeout = request.TimeoutMilliseconds is { } milliseconds + ? new CancellationTokenSource(milliseconds) + : null; + using var linked = timeout is null + ? null + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + try + { + await socket.ConnectAsync( + request.Endpoint, + linked?.Token ?? cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException exception) when ( + timeout?.IsCancellationRequested == true && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"WebSocket connect timeout after {request.TimeoutMilliseconds}ms.", + exception); + } + + return new ClientOpenAIWebSocketConnection(socket); + } + catch + { + socket.Dispose(); + throw; + } + } + + public async ValueTask SendTextAsync(string text, CancellationToken cancellationToken) + { + if (text is null) + { + throw new ArgumentNullException(nameof(text)); + } + + ThrowIfDisposed(); + var bytes = Encoding.UTF8.GetBytes(text); + await _socket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask ReceiveTextAsync( + int maximumCharacters, + CancellationToken cancellationToken) + { + if (maximumCharacters < 1) + { + throw new ArgumentOutOfRangeException(nameof(maximumCharacters)); + } + + ThrowIfDisposed(); + var maximumBytes = checked((long)maximumCharacters * 4L); + using var buffer = new MemoryStream(); + var rented = ArrayPool.Shared.Rent(8192); + try + { + WebSocketReceiveResult result; + do + { + result = await _socket.ReceiveAsync( + new ArraySegment(rented), + cancellationToken) + .ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + var suffix = string.IsNullOrWhiteSpace(result.CloseStatusDescription) + ? string.Empty + : " " + result.CloseStatusDescription; + throw new IOException( + $"WebSocket closed with status {result.CloseStatus?.ToString() ?? "unknown"}.{suffix}".TrimEnd()); + } + + if (result.MessageType != WebSocketMessageType.Text) + { + throw new InvalidDataException( + "The WebSocket response event was not a text message."); + } + + if (buffer.Length + result.Count > maximumBytes) + { + throw new InvalidDataException( + "The WebSocket response event exceeded the configured character limit."); + } + + buffer.Write(rented, 0, result.Count); + } + while (!result.EndOfMessage); + + string text; + try + { + text = StrictUtf8.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length)); + } + catch (DecoderFallbackException exception) + { + throw new InvalidDataException("The WebSocket response was not valid UTF-8.", exception); + } + + if (text.Length > maximumCharacters) + { + throw new InvalidDataException( + "The WebSocket response event exceeded the configured character limit."); + } + + return text; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + public async ValueTask CloseAsync(string reason, CancellationToken cancellationToken) + { + if (_disposed || _socket.State is WebSocketState.Closed or WebSocketState.Aborted) + { + return; + } + + var boundedReason = string.IsNullOrEmpty(reason) + ? "done" + : reason.Length <= 123 ? reason : reason.Substring(0, 123); + try + { + await _socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + boundedReason, + cancellationToken) + .ConfigureAwait(false); + } + catch (WebSocketException) + { + _socket.Abort(); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _socket.Dispose(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ClientOpenAIWebSocketConnection)); + } + } +} diff --git a/src/OpenGameAgent.Providers.OpenAI/OpenGameAgent.Providers.OpenAI.csproj b/src/OpenGameAgent.Providers.OpenAI/OpenGameAgent.Providers.OpenAI.csproj new file mode 100644 index 0000000..1685613 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/OpenGameAgent.Providers.OpenAI.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + OpenGameAgent.Providers.OpenAI + Native OpenAI Responses transport for OpenGameAgent. + + + + + + + + + diff --git a/src/OpenGameAgent.Providers.OpenAI/ResponsesStreamState.cs b/src/OpenGameAgent.Providers.OpenAI/ResponsesStreamState.cs new file mode 100644 index 0000000..97ae2f1 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/ResponsesStreamState.cs @@ -0,0 +1,858 @@ +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.OpenAI; + +internal sealed class ResponsesStreamState +{ + private readonly string _requestModel; + private readonly string _providerId; + private readonly string _apiId; + private readonly IReadOnlyDictionary _grammarInputProperties; + private readonly int _maximumCharacters; + private readonly int _maximumToolCalls; + private readonly SortedDictionary _slots = new(); + private long _characters; + private bool _terminal; + private string? _responseId; + private string? _responseModel; + private string? _rawStopReason; + private bool? _endTurn; + private ModelStopReason _stopReason = ModelStopReason.Pending; + private string? _errorMessage; + private ModelUsage _usage = new(); + private readonly List _diagnostics = new(); + + public ResponsesStreamState( + string requestModel, + string providerId, + string apiId, + IReadOnlyDictionary grammarInputProperties, + int maximumCharacters, + int maximumToolCalls) + { + _requestModel = requestModel; + _providerId = providerId; + _apiId = apiId; + _grammarInputProperties = grammarInputProperties; + _maximumCharacters = maximumCharacters; + _maximumToolCalls = maximumToolCalls; + } + + public IReadOnlyList Apply(string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + RequireKind(root, JsonValueKind.Object, "A Responses stream event must be a JSON object."); + EnsureUnambiguous(root); + var type = RequiredString(root, "type"); + var updates = new List(); + switch (type) + { + case "response.created": + if (root.TryGetProperty("response", out var created)) + { + ReadResponseIdentity(created); + } + + break; + case "response.output_item.added": + CreateSlot(RequiredIndex(root), RequiredObject(root, "item"), updates); + break; + case "response.reasoning_summary_text.delta": + case "response.reasoning_text.delta": + ApplyTextDelta(root, SlotKind.Reasoning, ModelStreamEventKind.ReasoningDelta, updates); + break; + case "response.reasoning_summary_part.done": + ApplyLiteralDelta(root, SlotKind.Reasoning, "\n\n", ModelStreamEventKind.ReasoningDelta, updates); + break; + case "response.output_text.delta": + case "response.refusal.delta": + ApplyTextDelta(root, SlotKind.Text, ModelStreamEventKind.TextDelta, updates); + break; + case "response.function_call_arguments.delta": + ApplyFunctionArgumentsDelta(root, updates); + break; + case "response.function_call_arguments.done": + ApplyFunctionArgumentsDone(root, updates); + break; + case "response.custom_tool_call_input.delta": + ApplyCustomInputDelta(root, updates); + break; + case "response.custom_tool_call_input.done": + ApplyCustomInputDone(root, updates); + break; + case "response.output_item.done": + CompleteSlot(RequiredIndex(root), RequiredObject(root, "item"), updates); + break; + case "response.completed": + case "response.done": + case "response.incomplete": + CompleteResponse(RequiredObject(root, "response")); + break; + case "response.failed": + _terminal = true; + var failed = RequiredObject(root, "response"); + ReadResponseIdentity(failed); + _rawStopReason = OptionalString(failed, "status") ?? "failed"; + throw new InvalidDataException(ReadFailure(failed)); + case "error": + throw new InvalidDataException( + $"Responses stream error {OptionalString(root, "code") ?? "unknown"}: " + + (OptionalString(root, "message") ?? "No message was supplied.")); + } + + return updates; + } + catch (JsonException exception) + { + throw new InvalidDataException("The Responses stream contained invalid JSON.", exception); + } + catch (InvalidOperationException exception) + { + throw new InvalidDataException("The Responses stream did not match the expected response shape.", exception); + } + } + + public ModelResponse Partial() => BuildResponse(ModelStopReason.Pending, null); + + public void AddDiagnostic(ModelDiagnostic diagnostic) + { + if (diagnostic is null) + { + throw new ArgumentNullException(nameof(diagnostic)); + } + + _diagnostics.Add(diagnostic); + } + + public bool IsTerminal => _terminal; + + public ModelResponse Complete() + { + if (!_terminal || _stopReason == ModelStopReason.Pending) + { + throw new InvalidDataException("The Responses stream ended before a terminal response event."); + } + + if (_slots.Values.Any(slot => !slot.Ended)) + { + throw new InvalidDataException("The Responses stream ended with incomplete output items."); + } + + return BuildResponse(_stopReason, _errorMessage); + } + + private ModelResponse BuildResponse(ModelStopReason reason, string? errorMessage) + { + var content = new List(); + foreach (var slot in _slots.Values) + { + switch (slot.Kind) + { + case SlotKind.Reasoning: + content.Add(new ReasoningContent(slot.Buffer.ToString(), slot.Signature)); + break; + case SlotKind.Text: + content.Add(new TextContent(slot.Buffer.ToString(), slot.Signature, ParsePhase(slot.Phase))); + break; + case SlotKind.FunctionTool: + case SlotKind.CustomTool: + content.Add(CreateToolCall(slot, reason)); + break; + } + } + + return new ModelResponse( + content, + reason, + _usage, + errorMessage, + _providerId, + _apiId, + _responseModel ?? _requestModel, + _responseId, + _rawStopReason, + endTurn: _endTurn ?? (_slots.Values.Any(slot => slot.Phase == "final_answer") ? true : null), + diagnostics: _diagnostics); + } + + private void CreateSlot(int outputIndex, JsonElement item, ICollection updates) + { + if (_terminal) + { + throw new InvalidDataException("The Responses stream emitted output after its terminal event."); + } + + if (_slots.ContainsKey(outputIndex)) + { + throw new InvalidDataException("The Responses stream reused an output index."); + } + + var type = RequiredString(item, "type"); + OutputSlot? slot = type switch + { + "reasoning" => new OutputSlot(outputIndex, SlotKind.Reasoning), + "message" => new OutputSlot(outputIndex, SlotKind.Text), + "function_call" => CreateToolSlot(outputIndex, item, custom: false), + "custom_tool_call" => CreateToolSlot(outputIndex, item, custom: true), + _ => null, + }; + if (slot is null) + { + return; + } + + if (slot.Kind is SlotKind.FunctionTool or SlotKind.CustomTool + && _slots.Values.Count(value => value.Kind is SlotKind.FunctionTool or SlotKind.CustomTool) >= _maximumToolCalls) + { + throw new InvalidDataException("The Responses output exceeded the configured tool-call limit."); + } + + _slots.Add(outputIndex, slot); + var kind = slot.Kind switch + { + SlotKind.Reasoning => ModelStreamEventKind.ReasoningStarted, + SlotKind.Text => ModelStreamEventKind.TextStarted, + _ => ModelStreamEventKind.ToolCallStarted, + }; + updates.Add(ModelStreamEvent.Update( + kind, + Partial(), + contentIndex: ContentIndex(outputIndex), + toolCallId: slot.Kind is SlotKind.FunctionTool or SlotKind.CustomTool ? ToolCallId(slot) : null, + toolName: slot.Name)); + } + + private OutputSlot CreateToolSlot(int outputIndex, JsonElement item, bool custom) + { + var name = RequiredString(item, "name"); + var slot = new OutputSlot(outputIndex, custom ? SlotKind.CustomTool : SlotKind.FunctionTool) + { + CallId = RequiredString(item, "call_id"), + ItemId = RequiredString(item, "id"), + Name = name, + Namespace = OptionalString(item, "namespace"), + }; + if (custom) + { + slot.CustomProperty = _grammarInputProperties.TryGetValue(name, out var property) ? property : "input"; + slot.CustomInput.Append(OptionalString(item, "input") ?? string.Empty); + } + else + { + slot.Buffer.Append(OptionalString(item, "arguments") ?? string.Empty); + } + + return slot; + } + + private void CompleteSlot(int outputIndex, JsonElement item, ICollection updates) + { + if (!_slots.TryGetValue(outputIndex, out var slot)) + { + CreateSlot(outputIndex, item, updates); + if (!_slots.TryGetValue(outputIndex, out slot)) + { + return; + } + } + + if (slot.Ended) + { + throw new InvalidDataException("A Responses output item ended more than once."); + } + + var type = RequiredString(item, "type"); + switch (slot.Kind) + { + case SlotKind.Reasoning when type == "reasoning": + var reasoningText = JoinContentText(item, "summary", "content"); + ReplaceIfNonEmpty(slot.Buffer, reasoningText); + slot.Signature = item.GetRawText(); + AddCharacters(slot.Signature.Length); + break; + case SlotKind.Text when type == "message": + var text = JoinMessageText(item); + ReplaceIfNonEmpty(slot.Buffer, text); + slot.Phase = OptionalString(item, "phase"); + var id = RequiredString(item, "id"); + slot.Signature = JsonSerializer.Serialize(new Dictionary + { + ["v"] = 1, + ["id"] = id, + ["phase"] = slot.Phase, + }); + break; + case SlotKind.FunctionTool when type == "function_call": + ReplaceIfPresent(slot.Buffer, OptionalString(item, "arguments")); + slot.Namespace = OptionalString(item, "namespace") ?? slot.Namespace; + break; + case SlotKind.CustomTool when type == "custom_tool_call": + ReplaceCustomInput(slot, OptionalString(item, "input") ?? slot.CustomInput.ToString(), updates, close: true); + slot.Namespace = OptionalString(item, "namespace") ?? slot.Namespace; + break; + default: + throw new InvalidDataException("A Responses output item changed type before completion."); + } + + slot.Ended = true; + var kind = slot.Kind switch + { + SlotKind.Reasoning => ModelStreamEventKind.ReasoningEnded, + SlotKind.Text => ModelStreamEventKind.TextEnded, + _ => ModelStreamEventKind.ToolCallEnded, + }; + var contentIndex = ContentIndex(outputIndex); + var partial = Partial(); + var toolCall = kind == ModelStreamEventKind.ToolCallEnded + ? CreateToolCall(slot, ModelStopReason.Pending) + : null; + updates.Add(ModelStreamEvent.Update( + kind, + partial, + contentIndex: contentIndex, + toolCall: toolCall, + content: kind is ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded + ? slot.Buffer.ToString() + : null)); + } + + private static ToolCallContent CreateToolCall(OutputSlot slot, ModelStopReason reason) + { + var arguments = slot.Buffer.Length == 0 ? "{}" : slot.Buffer.ToString(); + if (reason is ModelStopReason.Pending or ModelStopReason.Length) + { + arguments = StreamingJson.ParseObject(arguments); + } + + if (!IsJsonObject(arguments)) + { + throw new InvalidDataException("A completed Responses tool call did not contain a JSON object."); + } + + return new ToolCallContent( + ToolCallId(slot), + slot.Name!, + arguments, + slot.ThoughtSignature, + slot.Namespace); + } + + private static string ToolCallId(OutputSlot slot) => slot.CallId + "|" + slot.ItemId; + + private void ApplyTextDelta( + JsonElement root, + SlotKind expected, + ModelStreamEventKind kind, + ICollection updates) + { + ApplyLiteralDelta(root, expected, RequiredString(root, "delta"), kind, updates); + } + + private void ApplyLiteralDelta( + JsonElement root, + SlotKind expected, + string delta, + ModelStreamEventKind kind, + ICollection updates) + { + var outputIndex = RequiredIndex(root); + var slot = RequiredSlot(outputIndex, expected); + AddCharacters(delta.Length); + slot.Buffer.Append(delta); + updates.Add(ModelStreamEvent.Update(kind, Partial(), delta, ContentIndex(outputIndex))); + } + + private void ApplyFunctionArgumentsDelta(JsonElement root, ICollection updates) + { + var outputIndex = RequiredIndex(root); + var slot = RequiredSlot(outputIndex, SlotKind.FunctionTool); + var delta = RequiredString(root, "delta"); + AddCharacters(delta.Length); + slot.Buffer.Append(delta); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + delta, + ContentIndex(outputIndex), + ToolCallId(slot), + slot.Name)); + } + + private void ApplyFunctionArgumentsDone(JsonElement root, ICollection updates) + { + var outputIndex = RequiredIndex(root); + var slot = RequiredSlot(outputIndex, SlotKind.FunctionTool); + var complete = RequiredString(root, "arguments"); + if (!complete.StartsWith(slot.Buffer.ToString(), StringComparison.Ordinal)) + { + throw new InvalidDataException("Completed tool arguments changed previously streamed content."); + } + + var delta = complete.Substring(slot.Buffer.Length); + if (delta.Length > 0) + { + AddCharacters(delta.Length); + slot.Buffer.Append(delta); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + delta, + ContentIndex(outputIndex), + ToolCallId(slot), + slot.Name)); + } + } + + private void ApplyCustomInputDelta(JsonElement root, ICollection updates) + { + var outputIndex = RequiredIndex(root); + var slot = RequiredSlot(outputIndex, SlotKind.CustomTool); + ReplaceCustomInput(slot, slot.CustomInput + RequiredString(root, "delta"), updates, close: false); + } + + private void ApplyCustomInputDone(JsonElement root, ICollection updates) + { + var outputIndex = RequiredIndex(root); + var slot = RequiredSlot(outputIndex, SlotKind.CustomTool); + ReplaceCustomInput(slot, RequiredString(root, "input"), updates, close: true); + } + + private void ReplaceCustomInput( + OutputSlot slot, + string nextInput, + ICollection updates, + bool close) + { + var previous = slot.CustomInput.ToString(); + if (!nextInput.StartsWith(previous, StringComparison.Ordinal)) + { + throw new InvalidDataException("A custom-tool input changed non-monotonically."); + } + + var inputDelta = nextInput.Substring(previous.Length); + slot.CustomInput.Clear(); + slot.CustomInput.Append(nextInput); + var property = slot.CustomProperty!; + var targetJson = JsonSerializer.Serialize(new Dictionary { [property] = nextInput }); + var currentJson = slot.Buffer.ToString(); + string? jsonDelta; + if (!close) + { + var openTarget = targetJson.Substring(0, targetJson.Length - 2); + jsonDelta = openTarget.StartsWith(currentJson, StringComparison.Ordinal) + ? openTarget.Substring(currentJson.Length) + : null; + targetJson = openTarget; + } + else + { + jsonDelta = targetJson.StartsWith(currentJson, StringComparison.Ordinal) + ? targetJson.Substring(currentJson.Length) + : null; + } + + if (jsonDelta is null) + { + throw new InvalidDataException("A custom-tool JSON projection changed non-monotonically."); + } + + slot.Buffer.Clear(); + slot.Buffer.Append(targetJson); + if (jsonDelta.Length > 0) + { + AddCharacters(inputDelta.Length); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallDelta, + Partial(), + jsonDelta, + ContentIndex(slot.OutputIndex), + ToolCallId(slot), + slot.Name)); + } + } + + private void CompleteResponse(JsonElement response) + { + if (_terminal) + { + throw new InvalidDataException("The Responses stream emitted more than one terminal response."); + } + + _terminal = true; + ReadResponseIdentity(response); + if (response.TryGetProperty("end_turn", out var endTurn) + && endTurn.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + _endTurn = endTurn.GetBoolean(); + } + BackfillReasoningSignatures(response); + ReadUsage(response); + var status = OptionalString(response, "status") ?? "completed"; + var incompleteReason = response.TryGetProperty("incomplete_details", out var details) + && details.ValueKind == JsonValueKind.Object + ? OptionalString(details, "reason") + : null; + _rawStopReason = incompleteReason is null ? status : status + "." + incompleteReason; + switch (status) + { + case "completed": + _stopReason = _slots.Values.Any(slot => slot.Kind is SlotKind.FunctionTool or SlotKind.CustomTool) + ? ModelStopReason.ToolUse + : ModelStopReason.Stop; + break; + case "incomplete" when incompleteReason == "max_output_tokens": + _stopReason = ModelStopReason.Length; + break; + case "incomplete": + _stopReason = ModelStopReason.Error; + _errorMessage = incompleteReason is null + ? "The provider returned an incomplete response without a reason." + : "The provider returned an incomplete response: " + incompleteReason; + break; + case "failed": + case "cancelled": + _stopReason = ModelStopReason.Error; + _errorMessage = ReadFailure(response); + break; + case "in_progress": + case "queued": + _stopReason = ModelStopReason.Error; + _errorMessage = "The streaming response terminated while still " + status + "."; + break; + default: + _stopReason = ModelStopReason.Error; + _errorMessage = "The provider returned unsupported response status '" + status + "'."; + break; + } + } + + private void ReadResponseIdentity(JsonElement response) + { + ReadStableString(response, "id", ref _responseId, "response ID"); + ReadStableString(response, "model", ref _responseModel, "response model"); + } + + private void ReadUsage(JsonElement response) + { + if (!response.TryGetProperty("usage", out var usage) || usage.ValueKind == JsonValueKind.Null) + { + return; + } + + RequireKind(usage, JsonValueKind.Object, "Responses usage must be an object."); + var input = NonNegativeLong(usage, "input_tokens"); + var output = NonNegativeLong(usage, "output_tokens"); + var cacheRead = 0L; + var cacheWrite = 0L; + var reasoning = 0L; + if (usage.TryGetProperty("input_tokens_details", out var inputDetails) + && inputDetails.ValueKind == JsonValueKind.Object) + { + cacheRead = NonNegativeLong(inputDetails, "cached_tokens"); + cacheWrite = NonNegativeLong(inputDetails, "cache_write_tokens"); + } + + if (usage.TryGetProperty("output_tokens_details", out var outputDetails) + && outputDetails.ValueKind == JsonValueKind.Object) + { + reasoning = NonNegativeLong(outputDetails, "reasoning_tokens"); + } + + if (cacheRead + cacheWrite > input || reasoning > output) + { + throw new InvalidDataException("Responses usage contains inconsistent token subsets."); + } + + _usage = new ModelUsage(input - cacheRead - cacheWrite, output, cacheRead, cacheWrite, reasoning); + } + + private void BackfillReasoningSignatures(JsonElement response) + { + if (!response.TryGetProperty("output", out var output) || output.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var item in output.EnumerateArray()) + { + if (OptionalString(item, "type") != "reasoning") + { + continue; + } + + var id = OptionalString(item, "id"); + if (id is null) + { + continue; + } + + var slot = _slots.Values.FirstOrDefault(candidate => candidate.Kind == SlotKind.Reasoning + && candidate.Signature?.Contains(id, StringComparison.Ordinal) == true); + if (slot is not null) + { + slot.Signature = item.GetRawText(); + } + } + } + + private OutputSlot RequiredSlot(int outputIndex, SlotKind expected) + { + if (!_slots.TryGetValue(outputIndex, out var slot) || slot.Kind != expected || slot.Ended) + { + throw new InvalidDataException("A Responses delta referenced a missing, ended, or incompatible output item."); + } + + return slot; + } + + private int ContentIndex(int outputIndex) => _slots.Keys.TakeWhile(key => key != outputIndex).Count(); + + private void ReplaceIfNonEmpty(StringBuilder buffer, string value) + { + if (value.Length == 0) + { + return; + } + + AddCharacters(Math.Max(0, value.Length - buffer.Length)); + buffer.Clear(); + buffer.Append(value); + } + + private void ReplaceIfPresent(StringBuilder buffer, string? value) + { + if (value is not null) + { + ReplaceIfNonEmpty(buffer, value); + } + } + + private void AddCharacters(int count) + { + _characters += count; + if (_characters > _maximumCharacters) + { + throw new InvalidDataException("The accumulated Responses output exceeded the configured size limit."); + } + } + + private static string JoinContentText(JsonElement item, params string[] properties) + { + foreach (var property in properties) + { + if (!item.TryGetProperty(property, out var array) || array.ValueKind != JsonValueKind.Array) + { + continue; + } + + var values = array.EnumerateArray() + .Select(element => OptionalString(element, "text")) + .Where(value => value is not null) + .ToArray(); + if (values.Length > 0) + { + return string.Join("\n\n", values!); + } + } + + return string.Empty; + } + + private static string JoinMessageText(JsonElement item) + { + if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) + { + return string.Empty; + } + + return string.Concat(content.EnumerateArray().Select(part => + OptionalString(part, "text") ?? OptionalString(part, "refusal") ?? string.Empty)); + } + + private static AgentTextPhase? ParsePhase(string? phase) => phase switch + { + "commentary" => AgentTextPhase.Commentary, + "final_answer" => AgentTextPhase.FinalAnswer, + _ => null, + }; + + private static string ReadFailure(JsonElement response) + { + if (response.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) + { + return (OptionalString(error, "code") ?? "unknown") + ": " + + (OptionalString(error, "message") ?? "No provider message was supplied."); + } + + return "The provider returned a failed response without error details."; + } + + private static int RequiredIndex(JsonElement root) + { + if (!root.TryGetProperty("output_index", out var value) + || !value.TryGetInt32(out var index) + || index < 0) + { + throw new InvalidDataException("A Responses output index must be a non-negative integer."); + } + + return index; + } + + private static JsonElement RequiredObject(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException($"Responses event field '{property}' must be an object."); + } + + return value; + } + + private static string RequiredString(JsonElement root, string property) => + OptionalString(root, property) + ?? throw new InvalidDataException($"Responses event field '{property}' must be a non-empty string."); + + private static string? OptionalString(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException($"Responses event field '{property}' must be a string or null."); + } + + return value.GetString(); + } + + private static long NonNegativeLong(JsonElement root, string property) + { + if (!root.TryGetProperty(property, out var value)) + { + return 0; + } + + if (!value.TryGetInt64(out var number) || number < 0) + { + throw new InvalidDataException($"Responses usage field '{property}' must be a non-negative integer."); + } + + return number; + } + + private static void ReadStableString( + JsonElement root, + string property, + ref string? destination, + string label) + { + var incoming = OptionalString(root, property); + if (incoming is null) + { + return; + } + + if (destination is not null && destination != incoming) + { + throw new InvalidDataException("The Responses stream changed its " + label + "."); + } + + destination = incoming; + } + + private static bool IsJsonObject(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } + + private static void RequireKind(JsonElement value, JsonValueKind expected, string message) + { + if (value.ValueKind != expected) + { + throw new InvalidDataException(message); + } + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("The Responses stream contains duplicate JSON property names."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private enum SlotKind + { + Reasoning, + Text, + FunctionTool, + CustomTool, + } + + private sealed class OutputSlot + { + public OutputSlot(int outputIndex, SlotKind kind) + { + OutputIndex = outputIndex; + Kind = kind; + } + + public int OutputIndex { get; } + + public SlotKind Kind { get; } + + public StringBuilder Buffer { get; } = new(); + + public StringBuilder CustomInput { get; } = new(); + + public string? CustomProperty { get; set; } + + public string? Signature { get; set; } + + public string? Phase { get; set; } + + public string? CallId { get; set; } + + public string? ItemId { get; set; } + + public string? Name { get; set; } + + public string? Namespace { get; set; } + + public string? ThoughtSignature { get; set; } + + public bool Ended { get; set; } + } +} diff --git a/src/OpenGameAgent.Providers.OpenAI/packages.lock.json b/src/OpenGameAgent.Providers.OpenAI/packages.lock.json new file mode 100644 index 0000000..775e1fc --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAI/packages.lock.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs b/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs index 1ca7518..481c154 100644 --- a/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs +++ b/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs @@ -8,11 +8,13 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; namespace OpenGameAgent.Providers.OpenAICompatible; @@ -20,6 +22,124 @@ namespace OpenGameAgent.Providers.OpenAICompatible; public delegate string? OpenAICompatibleResourcePartProjector(ResourceContent resource); +public enum OpenAICompatibleMaxTokensField +{ + MaxTokens, + MaxCompletionTokens, +} + +public enum OpenAICompatibleThinkingFormat +{ + OpenAI, + OpenRouter, + DeepSeek, + Together, + Baseten, + Zai, + Qwen, + ChatTemplate, + QwenChatTemplate, + StringThinking, + AntLing, +} + +public enum OpenAICompatibleSessionAffinityFormat +{ + OpenAI, + OpenAIWithoutSessionHeader, + OpenRouter, +} + +public enum OpenAICompatibleCacheControlFormat +{ + None, + Anthropic, +} + +public enum OpenAICompatibleDeferredToolsMode +{ + None, + Kimi, +} + +/// +/// Explicit protocol switches for endpoints that implement different subsets of the +/// chat-completions wire format. Values are snapshotted when the provider is constructed. +/// +public sealed class OpenAICompatibleProtocolOptions +{ + public bool SupportsStore { get; set; } + + public bool SupportsDeveloperRole { get; set; } + + public bool SupportsReasoningEffort { get; set; } = true; + + public bool SupportsUsageInStreaming { get; set; } = true; + + public bool SupportsFinishReason { get; set; } = true; + + public OpenAICompatibleMaxTokensField MaxTokensField { get; set; } = OpenAICompatibleMaxTokensField.MaxTokens; + + public bool RequiresToolResultName { get; set; } + + public bool RequiresAssistantAfterToolResult { get; set; } + + public bool RequiresThinkingAsText { get; set; } + + public bool RequiresReasoningContentOnAssistantMessages { get; set; } + + public OpenAICompatibleThinkingFormat ThinkingFormat { get; set; } = OpenAICompatibleThinkingFormat.OpenAI; + + public bool ZaiToolStream { get; set; } + + public bool SupportsThinkingTokenBudget { get; set; } + + public bool SupportsStrictMode { get; set; } = true; + + public bool SupportsGrammarTools { get; set; } + + public OpenAICompatibleCacheControlFormat CacheControlFormat { get; set; } + + public bool SendSessionAffinityHeaders { get; set; } + + public OpenAICompatibleSessionAffinityFormat SessionAffinityFormat { get; set; } = + OpenAICompatibleSessionAffinityFormat.OpenAI; + + public OpenAICompatibleDeferredToolsMode DeferredToolsMode { get; set; } + + public bool SupportsLongCacheRetention { get; set; } = true; + + public string? ChatTemplateArgumentsJson { get; set; } + + public string? ChatTemplateKeywordArgumentsJson { get; set; } + + internal OpenAICompatibleProtocolOptions Copy() => new() + { + SupportsStore = SupportsStore, + SupportsDeveloperRole = SupportsDeveloperRole, + SupportsReasoningEffort = SupportsReasoningEffort, + SupportsUsageInStreaming = SupportsUsageInStreaming, + SupportsFinishReason = SupportsFinishReason, + MaxTokensField = MaxTokensField, + RequiresToolResultName = RequiresToolResultName, + RequiresAssistantAfterToolResult = RequiresAssistantAfterToolResult, + RequiresThinkingAsText = RequiresThinkingAsText, + RequiresReasoningContentOnAssistantMessages = RequiresReasoningContentOnAssistantMessages, + ThinkingFormat = ThinkingFormat, + ZaiToolStream = ZaiToolStream, + SupportsThinkingTokenBudget = SupportsThinkingTokenBudget, + SupportsStrictMode = SupportsStrictMode, + SupportsGrammarTools = SupportsGrammarTools, + CacheControlFormat = CacheControlFormat, + SendSessionAffinityHeaders = SendSessionAffinityHeaders, + SessionAffinityFormat = SessionAffinityFormat, + DeferredToolsMode = DeferredToolsMode, + SupportsLongCacheRetention = SupportsLongCacheRetention, + ChatTemplateArgumentsJson = ChatTemplateArgumentsJson, + ChatTemplateKeywordArgumentsJson = ChatTemplateKeywordArgumentsJson, + }; +} + public sealed class OpenAICompatibleProviderOptions { public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) @@ -53,7 +173,13 @@ public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) public bool AllowInsecureHttp { get; set; } - public IDictionary Headers { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ProviderResponseObserver? ResponseObserver { get; set; } + + public int ResponseObserverTimeoutMilliseconds { get; set; } = + ProviderResponseObserverRunner.DefaultTimeoutMilliseconds; public int MaxEventCharacters { get; set; } = 4_000_000; @@ -86,9 +212,15 @@ public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) /// Return null to use the built-in image projection or plain resource text fallback. /// public OpenAICompatibleResourcePartProjector? ProjectResourcePart { get; set; } + + public string ProviderId { get; set; } = "openai-compatible"; + + public string ApiId { get; set; } = "openai-completions"; + + public OpenAICompatibleProtocolOptions Protocol { get; } = new(); } -public sealed class OpenAICompatibleProvider : IModelProvider +public sealed class OpenAICompatibleProvider : IModelProvider, IModelProviderCapabilities { private readonly HttpClient _httpClient; private readonly Uri _endpoint; @@ -96,7 +228,9 @@ public sealed class OpenAICompatibleProvider : IModelProvider private readonly ApiKeyProvider? _getApiKey; private readonly string _apiKeyHeader; private readonly string _apiKeyScheme; - private readonly IReadOnlyDictionary _headers; + private readonly IReadOnlyDictionary _headers; + private readonly ProviderResponseObserver? _responseObserver; + private readonly int _responseObserverTimeoutMilliseconds; private readonly int _maxEventCharacters; private readonly int _maxErrorCharacters; private readonly int _maxRequestBytes; @@ -107,6 +241,16 @@ public sealed class OpenAICompatibleProvider : IModelProvider private readonly IReadOnlyList _reasoningDeltaFields; private readonly Action? _onAuthenticationFailure; private readonly OpenAICompatibleResourcePartProjector? _projectResourcePart; + private readonly string _providerId; + private readonly string _apiId; + private readonly OpenAICompatibleProtocolOptions _protocol; + private readonly IReadOnlyCollection _supportedApis; + + public IReadOnlyCollection SupportedApis => _supportedApis; + + public bool SupportsNativeDeferredTools => _protocol.DeferredToolsMode != OpenAICompatibleDeferredToolsMode.None; + + public bool SupportsDeferredResponses => false; public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) { @@ -140,6 +284,11 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) throw new ArgumentOutOfRangeException(nameof(options), "The maximum tool-call count is invalid."); } + if (options.ResponseObserverTimeoutMilliseconds is < 1 or > 30_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "The response observer timeout is invalid."); + } + var reasoningFields = options.ReasoningDeltaFields .Select(field => string.IsNullOrWhiteSpace(field) || field.Length > 128 ? throw new ArgumentException("Reasoning delta field names must contain 1 to 128 characters.", nameof(options)) @@ -151,11 +300,6 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) throw new ArgumentException("At least one reasoning delta field is required.", nameof(options)); } - if (options.Headers.Count > 64) - { - throw new ArgumentException("At most 64 custom headers may be configured.", nameof(options)); - } - if (reasoningFields.Any(field => field is "content" or "tool_calls" or "role")) { throw new ArgumentException("Reasoning delta fields cannot reuse core stream fields.", nameof(options)); @@ -180,16 +324,19 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) nameof(options)); } + if (ProviderHeaderGuard.IsTransportControlledHeader(options.ApiKeyHeader)) + { + throw new ArgumentException("The API key header is controlled by the transport.", nameof(options)); + } + ValidateHeader(options.ApiKeyHeader, string.Empty, nameof(options)); ValidateCredential(options.ApiKey, nameof(options)); ValidateCredential(options.ApiKeyScheme, nameof(options)); - foreach (var header in options.Headers) - { - ValidateHeader(header.Key, header.Value, nameof(options)); - } + ProviderHeaderGuard.ValidateMerge(options.Headers, nameof(options)); if ((!string.IsNullOrEmpty(options.ApiKey) || options.GetApiKeyAsync is not null) - && options.Headers.ContainsKey(options.ApiKeyHeader)) + && options.Headers.TryGetValue(options.ApiKeyHeader, out var configuredApiKeyHeader) + && configuredApiKeyHeader is not null) { throw new ArgumentException("Custom headers cannot also define the configured API key header.", nameof(options)); } @@ -202,7 +349,9 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) ? throw new ArgumentException("An API key header is required.", nameof(options)) : options.ApiKeyHeader; _apiKeyScheme = options.ApiKeyScheme ?? string.Empty; - _headers = new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase); + _headers = new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase); + _responseObserver = options.ResponseObserver; + _responseObserverTimeoutMilliseconds = options.ResponseObserverTimeoutMilliseconds; _maxEventCharacters = options.MaxEventCharacters; _maxErrorCharacters = options.MaxErrorCharacters; _maxRequestBytes = options.MaxRequestBytes; @@ -213,6 +362,53 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) _reasoningDeltaFields = Array.AsReadOnly(reasoningFields); _onAuthenticationFailure = options.OnAuthenticationFailure; _projectResourcePart = options.ProjectResourcePart; + _providerId = RequireIdentifier(options.ProviderId, nameof(options)); + _apiId = RequireIdentifier(options.ApiId, nameof(options)); + _supportedApis = Array.AsReadOnly(new[] { _apiId }); + _protocol = options.Protocol.Copy(); + ValidateProtocol(_protocol, nameof(options)); + } + + private static string RequireIdentifier(string value, string parameterName) => + string.IsNullOrWhiteSpace(value) || value.Length > 256 + ? throw new ArgumentException("Provider and API identifiers must contain 1 to 256 characters.", parameterName) + : value; + + private static void ValidateProtocol(OpenAICompatibleProtocolOptions protocol, string parameterName) + { + if (!Enum.IsDefined(typeof(OpenAICompatibleMaxTokensField), protocol.MaxTokensField) + || !Enum.IsDefined(typeof(OpenAICompatibleThinkingFormat), protocol.ThinkingFormat) + || !Enum.IsDefined(typeof(OpenAICompatibleCacheControlFormat), protocol.CacheControlFormat) + || !Enum.IsDefined(typeof(OpenAICompatibleSessionAffinityFormat), protocol.SessionAffinityFormat) + || !Enum.IsDefined(typeof(OpenAICompatibleDeferredToolsMode), protocol.DeferredToolsMode)) + { + throw new ArgumentException("One or more protocol compatibility values are invalid.", parameterName); + } + + ValidateJsonObject(protocol.ChatTemplateArgumentsJson, parameterName); + ValidateJsonObject(protocol.ChatTemplateKeywordArgumentsJson, parameterName); + } + + private static void ValidateJsonObject(string? json, string parameterName) + { + if (json is null) + { + return; + } + + try + { + using var document = JsonDocument.Parse(json); + EnsureUnambiguous(document.RootElement, "A protocol JSON object contains duplicate property names."); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("Protocol JSON settings must be JSON objects.", parameterName); + } + } + catch (JsonException exception) + { + throw new ArgumentException("Protocol JSON settings must contain valid JSON objects.", parameterName, exception); + } } private static void ValidateHeader(string name, string value, string parameterName) @@ -271,12 +467,20 @@ public async IAsyncEnumerable StreamAsync( throw new ArgumentNullException(nameof(request)); } + if (request.Parameters.Transport is ModelTransport.WebSocket or ModelTransport.CachedWebSocket) + { + throw new NotSupportedException("This provider uses server-sent events and cannot satisfy a WebSocket-only request."); + } + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _endpoint); var apiKey = _getApiKey is null ? _apiKey - : await _getApiKey(cancellationToken).ConfigureAwait(false); + : await ProviderCallbackRunner.RunAsync( + token => _getApiKey(token), + cancellationToken) + .ConfigureAwait(false); ValidateCredential(apiKey, nameof(OpenAICompatibleProviderOptions.GetApiKeyAsync)); - ApplyHeaders(httpRequest, apiKey); + ApplyHeaders(httpRequest, apiKey, request.SessionId); httpRequest.Content = new ByteArrayContent(SerializeRequest(request)); httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { @@ -287,6 +491,16 @@ public async IAsyncEnumerable StreamAsync( httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await ProviderResponseObserverRunner.NotifyAsync( + _responseObserver, + ProviderResponseObservation.FromHttpResponse( + _providerId, + _apiId, + request.Model, + response), + _responseObserverTimeoutMilliseconds, + cancellationToken) + .ConfigureAwait(false); if (!response.IsSuccessStatusCode) { Exception? authenticationFailureException = null; @@ -303,10 +517,11 @@ public async IAsyncEnumerable StreamAsync( } var error = await ReadBoundedAsync(response.Content, _maxErrorCharacters, cancellationToken).ConfigureAwait(false); + var retry = ProviderHttpRetryMetadata.FromResponse(response, errorText: error); throw new ModelProviderException( $"The model endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", - IsTransient(response), - GetRetryAfter(response), + retry.IsTransient, + retry.RetryAfter, (int)response.StatusCode, authenticationFailureException); } @@ -316,6 +531,8 @@ public async IAsyncEnumerable StreamAsync( using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); var state = new StreamState( request.Model, + _providerId, + _apiId, _maxResponseCharacters, _maxToolCallsPerResponse, _reasoningDeltaFields); @@ -348,18 +565,33 @@ public async IAsyncEnumerable StreamAsync( } } - if (!state.HasFinishReason && !(sawDone && _allowDoneWithoutFinishReason)) + if (!state.HasFinishReason + && _protocol.SupportsFinishReason + && !(sawDone && _allowDoneWithoutFinishReason)) { throw new InvalidDataException("The model stream ended before receiving a finish reason."); } + if (!state.HasFinishReason && !_protocol.SupportsFinishReason) + { + state.InferStopReason(); + } + yield return ModelStreamEvent.Terminal(state.Complete()); } - private void ApplyHeaders(HttpRequestMessage request, string? apiKey) + private void ApplyHeaders(HttpRequestMessage request, string? apiKey, string? sessionId) { + var suppressedHeaders = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var header in _headers) { + request.Headers.Remove(header.Key); + if (header.Value is null) + { + suppressedHeaders.Add(header.Key); + continue; + } + if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value)) { throw new InvalidOperationException($"Header '{header.Key}' is not valid for an HTTP request."); @@ -368,6 +600,7 @@ private void ApplyHeaders(HttpRequestMessage request, string? apiKey) if (string.IsNullOrEmpty(apiKey)) { + ApplySessionHeaders(request, sessionId, suppressedHeaders); return; } @@ -376,85 +609,80 @@ private void ApplyHeaders(HttpRequestMessage request, string? apiKey) { throw new InvalidOperationException($"API key header '{_apiKeyHeader}' is not valid for an HTTP request."); } - } - private static bool IsTransient(HttpResponseMessage response) - { - if (response.Headers.TryGetValues("x-should-retry", out var values)) - { - var directive = values.FirstOrDefault(); - if (string.Equals(directive, "true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(directive, "false", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - var status = (int)response.StatusCode; - return status is 408 or 409 or 429 || status >= 500; + ApplySessionHeaders(request, sessionId, suppressedHeaders); } - private static TimeSpan? GetRetryAfter(HttpResponseMessage response) + private void ApplySessionHeaders( + HttpRequestMessage request, + string? sessionId, + ISet suppressedHeaders) { - if (response.Headers.TryGetValues("retry-after-ms", out var millisecondValues) - && double.TryParse( - millisecondValues.FirstOrDefault(), - NumberStyles.Float, - CultureInfo.InvariantCulture, - out var milliseconds) - && !double.IsNaN(milliseconds) - && !double.IsInfinity(milliseconds)) + if (!_protocol.SendSessionAffinityHeaders || string.IsNullOrEmpty(sessionId)) { - return milliseconds >= TimeSpan.MaxValue.TotalMilliseconds - ? TimeSpan.MaxValue - : TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); + return; } - if (response.Headers.RetryAfter?.Delta is { } delta) + var headers = _protocol.SessionAffinityFormat switch { - return delta < TimeSpan.Zero ? TimeSpan.Zero : delta; - } - - if (response.Headers.RetryAfter?.Date is { } date) + OpenAICompatibleSessionAffinityFormat.OpenRouter => new[] { ("x-session-id", sessionId) }, + OpenAICompatibleSessionAffinityFormat.OpenAIWithoutSessionHeader => new[] + { + ("x-client-request-id", sessionId), + ("x-session-affinity", sessionId), + }, + _ => new[] + { + ("session_id", sessionId), + ("x-client-request-id", sessionId), + ("x-session-affinity", sessionId), + }, + }; + foreach (var header in headers) { - var delay = date - DateTimeOffset.UtcNow; - return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + if (!suppressedHeaders.Contains(header.Item1) + && !request.Headers.Contains(header.Item1) + && !request.Headers.TryAddWithoutValidation(header.Item1, header.Item2)) + { + throw new InvalidOperationException($"Session header '{header.Item1}' is not valid for an HTTP request."); + } } - - return null; } private byte[] SerializeRequest(ModelRequest request) { EnsureRequestCanFit(request); + var normalizedMessages = ProviderTranscript.Normalize( + request.Messages, + _providerId, + _apiId, + request.Model, + (id, _, _, _) => NormalizeChatToolCallId(id)); var payload = new Dictionary(StringComparer.Ordinal) { ["model"] = request.Model, - ["messages"] = ProjectMessages(request), + ["messages"] = ProjectMessages(request, normalizedMessages), ["stream"] = true, }; - if (_includeUsage) + if (_includeUsage && _protocol.SupportsUsageInStreaming) { payload["stream_options"] = new Dictionary { ["include_usage"] = true }; } - if (request.Tools.Count > 0) + if (_protocol.SupportsStore) { - payload["tools"] = request.Tools.Select(tool => new Dictionary - { - ["type"] = "function", - ["function"] = new Dictionary - { - ["name"] = tool.Name, - ["description"] = tool.Description, - ["parameters"] = ParseElement(tool.InputSchemaJson), - }, - }).ToArray(); + payload["store"] = false; + } + + var activeTools = ActiveTools(request, normalizedMessages); + if (activeTools.Count > 0) + { + payload["tools"] = ProjectTools(activeTools); payload["tool_choice"] = "auto"; + if (_protocol.ZaiToolStream) + { + payload["tool_stream"] = true; + } } if (request.Parameters.Temperature is { } temperature) @@ -464,13 +692,13 @@ private byte[] SerializeRequest(ModelRequest request) if (request.Parameters.MaxOutputTokens is { } maximum) { - payload["max_tokens"] = maximum; + payload[_protocol.MaxTokensField == OpenAICompatibleMaxTokensField.MaxCompletionTokens + ? "max_completion_tokens" + : "max_tokens"] = maximum; } - if (!string.IsNullOrWhiteSpace(request.Parameters.ReasoningLevel)) - { - payload["reasoning_effort"] = request.Parameters.ReasoningLevel; - } + ApplyReasoningParameters(payload, request.Parameters); + ApplyPromptCache(payload, request); foreach (var extension in request.Parameters.Extensions ?? new Dictionary()) { @@ -482,6 +710,8 @@ private byte[] SerializeRequest(ModelRequest request) payload[extension.Key] = ParseExtension(extension.Value); } + MergeSamplingParameters(payload, request.Parameters.SamplingParametersJson); + var body = JsonSerializer.SerializeToUtf8Bytes(payload); if (body.Length > _maxRequestBytes) { @@ -491,6 +721,311 @@ private byte[] SerializeRequest(ModelRequest request) return body; } + private IReadOnlyList ActiveTools( + ModelRequest request, + IReadOnlyList messages) + { + if (_protocol.DeferredToolsMode != OpenAICompatibleDeferredToolsMode.Kimi) + { + return request.Tools; + } + + var used = new HashSet(StringComparer.Ordinal); + var deferred = new HashSet(StringComparer.Ordinal); + foreach (var message in messages) + { + if (message.Role == AgentRole.Assistant) + { + foreach (var call in message.Content.OfType()) + { + used.Add(call.Name); + } + } + else if (message.Role == AgentRole.Tool) + { + foreach (var name in message.AddedToolNames) + { + if (!used.Contains(name)) + { + deferred.Add(name); + } + } + } + } + + return request.Tools.Where(tool => !deferred.Contains(tool.Name)).ToArray(); + } + + private object[] ProjectTools(IEnumerable tools) => tools.Select(ProjectTool).ToArray(); + + private object ProjectTool(ToolDefinition tool) + { + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.Grammar + && _protocol.SupportsGrammarTools) + { + var grammar = !string.IsNullOrWhiteSpace(tool.ConstrainedSampling.OpenAiLark) + ? (Syntax: "lark", Definition: tool.ConstrainedSampling.OpenAiLark!) + : (Syntax: "regex", Definition: tool.ConstrainedSampling.OpenAiRegex!); + _ = InferGrammarInputProperty(tool); + return new Dictionary + { + ["type"] = "custom", + ["custom"] = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["format"] = new Dictionary + { + ["type"] = "grammar", + ["grammar"] = new Dictionary + { + ["syntax"] = grammar.Syntax, + ["definition"] = grammar.Definition, + }, + }, + }, + }; + } + + var strict = false; + if (tool.ConstrainedSampling?.Kind == ToolConstrainedSamplingKind.JsonSchema) + { + if (!_protocol.SupportsStrictMode + && tool.ConstrainedSampling.Strictness == ToolSchemaStrictness.Require) + { + throw new InvalidOperationException( + $"Tool '{tool.Name}' requires strict JSON-schema sampling, but the endpoint does not support it."); + } + + strict = _protocol.SupportsStrictMode; + } + + var function = new Dictionary + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["parameters"] = ParseElement(tool.InputSchemaJson), + }; + if (_protocol.SupportsStrictMode) + { + function["strict"] = strict; + } + + return new Dictionary + { + ["type"] = "function", + ["function"] = function, + }; + } + + private static string InferGrammarInputProperty(ToolDefinition tool) + { + using var document = JsonDocument.Parse(tool.InputSchemaJson); + var root = document.RootElement; + if (!root.TryGetProperty("type", out var type) + || type.ValueKind != JsonValueKind.String + || type.GetString() != "object" + || !root.TryGetProperty("required", out var required) + || required.ValueKind != JsonValueKind.Array + || required.GetArrayLength() != 1 + || required[0].ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Grammar tool '{tool.Name}' requires an object schema with exactly one required string property."); + } + + var property = required[0].GetString()!; + if (!root.TryGetProperty("properties", out var properties) + || properties.ValueKind != JsonValueKind.Object + || !properties.TryGetProperty(property, out var schema) + || schema.ValueKind != JsonValueKind.Object + || !schema.TryGetProperty("type", out var propertyType) + || propertyType.ValueKind != JsonValueKind.String + || propertyType.GetString() != "string") + { + throw new InvalidOperationException( + $"Grammar tool '{tool.Name}' requires its sole required property to be declared as a string."); + } + + return property; + } + + private void ApplyReasoningParameters(IDictionary payload, ModelParameters parameters) + { + var effort = parameters.ReasoningLevel; + var hasEffort = !string.IsNullOrWhiteSpace(effort); + var enabled = hasEffort + && !string.Equals(effort, "none", StringComparison.OrdinalIgnoreCase) + && !string.Equals(effort, "off", StringComparison.OrdinalIgnoreCase) + && !string.Equals(effort, "disabled", StringComparison.OrdinalIgnoreCase); + switch (_protocol.ThinkingFormat) + { + case OpenAICompatibleThinkingFormat.OpenRouter: + if (hasEffort) + { + payload["reasoning"] = new Dictionary { ["effort"] = effort }; + } + break; + case OpenAICompatibleThinkingFormat.AntLing: + if (enabled) + { + payload["reasoning"] = new Dictionary { ["effort"] = effort }; + } + break; + case OpenAICompatibleThinkingFormat.DeepSeek: + payload["thinking"] = new Dictionary { ["type"] = enabled ? "enabled" : "disabled" }; + AddReasoningEffort(payload, enabled ? effort : null); + break; + case OpenAICompatibleThinkingFormat.Together: + payload["reasoning"] = new Dictionary { ["enabled"] = enabled }; + AddReasoningEffort(payload, enabled ? effort : null); + break; + case OpenAICompatibleThinkingFormat.Baseten: + AddTemplateValues(payload, "chat_template_args", _protocol.ChatTemplateArgumentsJson, effort, enabled); + AddReasoningEffort(payload, hasEffort ? effort : null); + break; + case OpenAICompatibleThinkingFormat.Zai: + payload["thinking"] = enabled + ? new Dictionary { ["type"] = "enabled", ["clear_thinking"] = false } + : new Dictionary { ["type"] = "disabled" }; + AddReasoningEffort(payload, enabled ? effort : null); + break; + case OpenAICompatibleThinkingFormat.Qwen: + payload["enable_thinking"] = enabled; + AddReasoningEffort(payload, enabled ? effort : null); + break; + case OpenAICompatibleThinkingFormat.ChatTemplate: + AddTemplateValues(payload, "chat_template_kwargs", _protocol.ChatTemplateKeywordArgumentsJson, effort, enabled); + break; + case OpenAICompatibleThinkingFormat.QwenChatTemplate: + payload["chat_template_kwargs"] = new Dictionary + { + ["enable_thinking"] = enabled, + ["preserve_thinking"] = true, + }; + break; + case OpenAICompatibleThinkingFormat.StringThinking: + payload["thinking"] = hasEffort ? effort : "disabled"; + break; + default: + AddReasoningEffort(payload, effort); + break; + } + + if (_protocol.SupportsThinkingTokenBudget + && enabled + && parameters.ReasoningBudgets.TryGetValue(effort!, out var budget)) + { + payload["thinking_token_budget"] = budget; + } + } + + private static void AddTemplateValues( + IDictionary payload, + string field, + string? json, + string? effort, + bool enabled) + { + if (json is null) + { + return; + } + + using var document = JsonDocument.Parse(json); + var values = new Dictionary(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) + { + values[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number when property.Value.TryGetInt64(out var integer) => integer, + JsonValueKind.Number => property.Value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => throw new InvalidOperationException("A chat-template value must be a scalar or variable."), + }; + continue; + } + + if (!property.Value.TryGetProperty("$var", out var variable) + || variable.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException("A chat-template variable is invalid."); + } + + var omitWhenOff = property.Value.TryGetProperty("omitWhenOff", out var omit) + && omit.ValueKind == JsonValueKind.True; + if (!enabled && omitWhenOff) + { + continue; + } + + object? resolved = variable.GetString() switch + { + "thinking.enabled" => enabled, + "thinking.effort" => effort, + var name => throw new InvalidOperationException($"Unknown chat-template variable '{name}'."), + }; + if (resolved is not null) + { + values[property.Name] = resolved; + } + } + + if (values.Count > 0) + { + payload[field] = values; + } + } + + private void AddReasoningEffort(IDictionary payload, string? effort) + { + if (_protocol.SupportsReasoningEffort && !string.IsNullOrWhiteSpace(effort)) + { + payload["reasoning_effort"] = effort; + } + } + + private void ApplyPromptCache(IDictionary payload, ModelRequest request) + { + if (request.Parameters.CacheRetention == ModelCacheRetention.None) + { + return; + } + + var supportsPromptCacheKey = _endpoint.Host.EndsWith("api.openai.com", StringComparison.OrdinalIgnoreCase) + || (request.Parameters.CacheRetention == ModelCacheRetention.Long + && _protocol.SupportsLongCacheRetention); + if (supportsPromptCacheKey && request.SessionId is { } sessionId) + { + payload["prompt_cache_key"] = sessionId.Length <= 64 ? sessionId : sessionId.Substring(0, 64); + } + + if (request.Parameters.CacheRetention == ModelCacheRetention.Long + && _protocol.SupportsLongCacheRetention) + { + payload["prompt_cache_retention"] = "24h"; + } + } + + private static void MergeSamplingParameters(IDictionary payload, string? json) + { + if (json is null) + { + return; + } + + using var document = JsonDocument.Parse(json); + foreach (var property in document.RootElement.EnumerateObject()) + { + payload[property.Name] = property.Value.Clone(); + } + } + private void EnsureRequestCanFit(ModelRequest request) { var lowerBound = 32L; @@ -553,6 +1088,13 @@ void AddString(string? value) AddString(resource.Uri); joinedParts++; break; + case BinaryContent binary: + AddBytes(32); + AddString(binary.Name); + AddString(binary.MediaType); + AddString(binary.Data); + joinedParts++; + break; case ToolCallContent genericCall: AddBytes(16); AddString(genericCall.Name); @@ -590,29 +1132,45 @@ private static JsonElement ParseElement(string json) return document.RootElement.Clone(); } - private IReadOnlyList ProjectMessages(ModelRequest request) + private IReadOnlyList ProjectMessages( + ModelRequest request, + IReadOnlyList messages) { var projected = new List { new Dictionary { - ["role"] = "system", + ["role"] = _protocol.SupportsDeveloperRole ? "developer" : "system", ["content"] = request.SystemPrompt, }, }; - for (var index = 0; index < request.Messages.Count; index++) + var lastWasToolResult = false; + for (var index = 0; index < messages.Count; index++) { - var message = request.Messages[index]; + var message = messages[index]; if (message.Role != AgentRole.Tool) { + if (lastWasToolResult + && message.Role is AgentRole.User or AgentRole.Custom + && _protocol.RequiresAssistantAfterToolResult) + { + projected.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = "I have processed the tool results.", + }); + } + projected.Add(ProjectMessage(message)); + lastWasToolResult = false; continue; } var attachments = new List(); - while (index < request.Messages.Count && request.Messages[index].Role == AgentRole.Tool) + var addedToolNames = new HashSet(StringComparer.Ordinal); + while (index < messages.Count && messages[index].Role == AgentRole.Tool) { - var toolMessage = request.Messages[index]; + var toolMessage = messages[index]; projected.Add(ProjectMessage(toolMessage)); foreach (var resource in toolMessage.Content.OfType()) { @@ -623,12 +1181,35 @@ private IReadOnlyList ProjectMessages(ModelRequest request) } } + foreach (var binary in toolMessage.Content.OfType()) + { + var attachment = ProjectNativeBinary(binary); + if (attachment is not null) + { + attachments.Add(attachment); + } + } + + foreach (var name in toolMessage.AddedToolNames) + { + addedToolNames.Add(name); + } + index++; } index--; if (attachments.Count > 0) { + if (_protocol.RequiresAssistantAfterToolResult) + { + projected.Add(new Dictionary + { + ["role"] = "assistant", + ["content"] = "I have processed the tool results.", + }); + } + var content = new List { new Dictionary @@ -643,6 +1224,25 @@ private IReadOnlyList ProjectMessages(ModelRequest request) ["role"] = "user", ["content"] = content, }); + lastWasToolResult = false; + } + else + { + lastWasToolResult = true; + } + + if (_protocol.DeferredToolsMode == OpenAICompatibleDeferredToolsMode.Kimi + && addedToolNames.Count > 0) + { + var loaded = request.Tools.Where(tool => addedToolNames.Contains(tool.Name)).ToArray(); + if (loaded.Length > 0) + { + projected.Add(new Dictionary + { + ["role"] = "system", + ["tools"] = ProjectTools(loaded), + }); + } } } @@ -653,10 +1253,18 @@ private object ProjectMessage(AgentMessage message) { if (message.Role == AgentRole.Assistant) { + var reasoning = message.Content.OfType() + .Where(content => !string.IsNullOrWhiteSpace(content.Text)) + .ToArray(); + var assistantContent = _protocol.RequiresThinkingAsText && reasoning.Length > 0 + ? string.Join("\n\n", reasoning.Select(content => content.Text) + .Concat(new[] { JoinContent(message.Content.Where(content => content is not ToolCallContent)) }) + .Where(text => text.Length > 0)) + : JoinContent(message.Content.Where(content => content is not ToolCallContent)); var assistant = new Dictionary { ["role"] = "assistant", - ["content"] = JoinContent(message.Content.Where(content => content is not ToolCallContent)), + ["content"] = assistantContent, }; var calls = message.Content.OfType().Select(call => new Dictionary { @@ -673,17 +1281,28 @@ private object ProjectMessage(AgentMessage message) assistant["tool_calls"] = calls; } - foreach (var reasoning in message.Content + foreach (var signedReasoning in message.Content .OfType() .Where(content => !string.IsNullOrWhiteSpace(content.Signature)) .GroupBy(content => content.Signature!, StringComparer.Ordinal)) { - if (assistant.ContainsKey(reasoning.Key)) + if (_protocol.RequiresThinkingAsText) + { + break; + } + + if (assistant.ContainsKey(signedReasoning.Key)) { throw new InvalidDataException("A reasoning signature cannot override a core assistant message field."); } - assistant[reasoning.Key] = string.Join("\n", reasoning.Select(content => content.Text)); + assistant[signedReasoning.Key] = string.Join("\n", signedReasoning.Select(content => content.Text)); + } + + if (_protocol.RequiresReasoningContentOnAssistantMessages + && !assistant.ContainsKey("reasoning_content")) + { + assistant["reasoning_content"] = string.Empty; } return assistant; @@ -691,12 +1310,18 @@ private object ProjectMessage(AgentMessage message) if (message.Role == AgentRole.Tool) { - return new Dictionary + var toolResult = new Dictionary { ["role"] = "tool", ["tool_call_id"] = message.ToolCallId, ["content"] = JoinContent(message.Content), }; + if (_protocol.RequiresToolResultName) + { + toolResult["name"] = message.ToolName; + } + + return toolResult; } const string role = "user"; @@ -716,7 +1341,7 @@ private object ProjectMessage(AgentMessage message) private object ProjectUserContent(AgentMessage message) { var visible = message.Content.Where(part => part is not ReasoningContent and not ToolCallContent).ToArray(); - if (!visible.Any(part => part is ResourceContent)) + if (!visible.Any(part => part is ResourceContent or BinaryContent)) { return JoinContent(visible); } @@ -730,6 +1355,12 @@ private object ProjectUserContent(AgentMessage message) continue; } + if (part is BinaryContent binary) + { + parts.Add(ProjectBinary(binary)); + continue; + } + var text = ContentText(part); if (text.Length > 0) { @@ -753,6 +1384,33 @@ private object ProjectResource(ResourceContent resource) }; } + private object ProjectBinary(BinaryContent binary) + { + return ProjectNativeBinary(binary) ?? new Dictionary + { + ["type"] = "text", + ["text"] = BinaryText(binary), + }; + } + + private static object? ProjectNativeBinary(BinaryContent binary) + { + if (binary.MediaKind == AgentMediaKind.Image + || binary.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary + { + ["type"] = "image_url", + ["image_url"] = new Dictionary + { + ["url"] = $"data:{binary.MediaType};base64,{binary.Data}", + }, + }; + } + + return null; + } + private object? ProjectNativeResource(ResourceContent resource) { var custom = _projectResourcePart?.Invoke(resource); @@ -819,6 +1477,7 @@ private static string JoinContent(IEnumerable content) TextContent text => text.Text, JsonContent json => json.Json, ResourceContent resource => ResourceText(resource), + BinaryContent binary => BinaryText(binary), ToolCallContent call => $"[tool_call {call.Name}] {call.ArgumentsJson}", _ => string.Empty, }; @@ -826,6 +1485,49 @@ private static string JoinContent(IEnumerable content) private static string ResourceText(ResourceContent resource) => $"[resource name={resource.Name ?? "unnamed"} media_type={resource.MediaType}] {resource.Uri}"; + private static string BinaryText(BinaryContent binary) => + $"[binary name={binary.Name ?? "unnamed"} media_type={binary.MediaType} data_omitted]"; + + private static string NormalizeChatToolCallId(string id) + { + var pieces = id.Split(new[] { '|' }, 2); + var callId = SanitizeId(pieces[0]); + var combined = pieces.Length == 2 && pieces[1].Length > 0 + ? callId + "_" + SanitizeId(pieces[1]) + : callId; + if (combined.Length <= 40) + { + return combined; + } + + var hash = ShortHash(id).Substring(0, 8); + return combined.Substring(0, Math.Max(1, 40 - hash.Length - 1)) + "_" + hash; + } + + private static string SanitizeId(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsLetterOrDigit(character) || character is '_' or '-' ? character : '_'); + } + + return builder.ToString(); + } + + private static string ShortHash(string value) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); + var builder = new StringBuilder(16); + for (var index = 0; index < 8; index++) + { + builder.Append(bytes[index].ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + private static object? ParseExtension(string value) { try @@ -974,6 +1676,7 @@ private sealed class StreamState private readonly StringBuilder _text = new(); private readonly StringBuilder _reasoning = new(); private readonly SortedDictionary _tools = new(); + private readonly List _contentOrder = new(); private ModelStopReason _stopReason = ModelStopReason.Stop; private ModelUsage _usage = new(); private string? _errorMessage; @@ -984,16 +1687,26 @@ private sealed class StreamState private readonly int _maximumCharacters; private readonly int _maximumToolCalls; private readonly IReadOnlyList _reasoningDeltaFields; + private readonly string _requestModel; + private readonly string _providerId; + private readonly string _apiId; private string? _reasoningSignature; + private string? _responseModel; + private string? _responseId; + private string? _rawStopReason; private long _characters; public StreamState( string model, + string providerId, + string apiId, int maximumCharacters, int maximumToolCalls, IReadOnlyList reasoningDeltaFields) { - _ = model; + _requestModel = model; + _providerId = providerId; + _apiId = apiId; _maximumCharacters = maximumCharacters; _maximumToolCalls = maximumToolCalls; _reasoningDeltaFields = reasoningDeltaFields; @@ -1007,6 +1720,7 @@ public IReadOnlyList Apply(string json) var root = document.RootElement; RequireKind(root, JsonValueKind.Object, "A model stream event must be a JSON object."); EnsureUnambiguous(root, "The model stream contains duplicate JSON property names."); + ReadResponseIdentity(root); if (root.TryGetProperty("error", out var error)) { var message = error.ValueKind == JsonValueKind.Object @@ -1113,6 +1827,7 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd builder = new ToolBuilder(); _tools.Add(index, builder); + _contentOrder.Add(new ContentSlot(ContentSlotKind.Tool, index)); created = true; } @@ -1143,7 +1858,7 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd updates.Add(ModelStreamEvent.Update( ModelStreamEventKind.ToolCallStarted, Partial(), - contentIndex: index, + contentIndex: ContentIndex(ContentSlotKind.Tool, index), toolCallId: builder.Id, toolName: builder.Name.Length == 0 ? null : builder.Name.ToString())); } @@ -1158,7 +1873,7 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd ModelStreamEventKind.ToolCallDelta, Partial(), argumentText, - index, + ContentIndex(ContentSlotKind.Tool, index), builder.Id, builder.Name.Length == 0 ? null : builder.Name.ToString())); } @@ -1168,7 +1883,7 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd updates.Add(ModelStreamEvent.Update( ModelStreamEventKind.ToolCallStarted, Partial(), - contentIndex: index, + contentIndex: ContentIndex(ContentSlotKind.Tool, index), toolCallId: builder.Id)); } } @@ -1215,29 +1930,47 @@ private int ResolveToolIndex(int? explicitIndex, string? incomingId) private void AddEndedEvents(ICollection updates) { - if (_reasoningStarted) - { - updates.Add(ModelStreamEvent.Update(ModelStreamEventKind.ReasoningEnded, Partial())); - } - - if (_textStarted) + foreach (var slot in _contentOrder) { - updates.Add(ModelStreamEvent.Update(ModelStreamEventKind.TextEnded, Partial())); - } - - foreach (var pair in _tools) - { - var tool = pair.Value; - updates.Add(ModelStreamEvent.Update( - ModelStreamEventKind.ToolCallEnded, - Partial(), - contentIndex: pair.Key, - toolCallId: tool.Id, - toolName: tool.Name.Length == 0 ? null : tool.Name.ToString())); + var contentIndex = _contentOrder.IndexOf(slot); + switch (slot.Kind) + { + case ContentSlotKind.Reasoning: + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ReasoningEnded, + Partial(), + contentIndex: contentIndex, + content: _reasoning.ToString())); + break; + case ContentSlotKind.Text: + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.TextEnded, + Partial(), + contentIndex: contentIndex, + content: _text.ToString())); + break; + case ContentSlotKind.Tool: + var tool = _tools[slot.ToolIndex]; + var toolCall = CreateToolCall(slot.ToolIndex, tool, _stopReason); + updates.Add(ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + Partial(), + contentIndex: contentIndex, + toolCall: toolCall)); + break; + } } } - public ModelResponse Partial() => new(CurrentContent(includeTools: false), ModelStopReason.Pending, _usage); + public ModelResponse Partial() => new( + CurrentContent(includeTools: true, ModelStopReason.Pending), + ModelStopReason.Pending, + _usage, + provider: _providerId, + api: _apiId, + responseModel: _responseModel ?? _requestModel, + responseId: _responseId, + rawStopReason: _rawStopReason); public bool HasFinishReason => _hasFinishReason; @@ -1250,44 +1983,76 @@ public ModelResponse Complete() throw new InvalidDataException("A completed model tool call is missing its ID or function name."); } - var content = CurrentContent(includeTools: true); - return new ModelResponse(content, _stopReason, _usage, _errorMessage); + var content = CurrentContent(includeTools: true, _stopReason); + return new ModelResponse( + content, + _stopReason, + _usage, + _errorMessage, + _providerId, + _apiId, + _responseModel ?? _requestModel, + _responseId, + _rawStopReason); } - private IReadOnlyList CurrentContent(bool includeTools) + public void InferStopReason() { - var content = new List(); - if (_reasoning.Length > 0) + if (_hasFinishReason) { - content.Add(new ReasoningContent(_reasoning.ToString(), _reasoningSignature)); + return; } - if (_text.Length > 0) - { - content.Add(new TextContent(_text.ToString())); - } + _stopReason = _tools.Count > 0 ? ModelStopReason.ToolUse : ModelStopReason.Stop; + _rawStopReason = null; + _hasFinishReason = true; + _contentEnded = true; + } - if (includeTools) + private IReadOnlyList CurrentContent(bool includeTools, ModelStopReason reason) + { + var content = new List(); + foreach (var slot in _contentOrder) { - foreach (var pair in _tools) + switch (slot.Kind) { - var tool = pair.Value; - var arguments = tool.Arguments.Length == 0 ? "{}" : tool.Arguments.ToString(); - if (_stopReason == ModelStopReason.Length && !IsJsonObject(arguments)) - { - arguments = "{}"; - } - - content.Add(new ToolCallContent( - string.IsNullOrWhiteSpace(tool.Id) ? "call_" + pair.Key : tool.Id, - tool.Name.Length == 0 ? "unknown_tool" : tool.Name.ToString(), - arguments)); + case ContentSlotKind.Reasoning: + content.Add(new ReasoningContent(_reasoning.ToString(), _reasoningSignature)); + break; + case ContentSlotKind.Text: + content.Add(new TextContent(_text.ToString())); + break; + case ContentSlotKind.Tool when includeTools: + content.Add(CreateToolCall(slot.ToolIndex, _tools[slot.ToolIndex], reason)); + break; } } return content; } + private static ToolCallContent CreateToolCall( + int index, + ToolBuilder tool, + ModelStopReason reason) + { + var arguments = tool.Arguments.Length == 0 ? "{}" : tool.Arguments.ToString(); + if (reason == ModelStopReason.Pending) + { + arguments = StreamingJson.ParseObject(arguments); + } + + if (reason == ModelStopReason.Length && !IsJsonObject(arguments)) + { + arguments = StreamingJson.ParseObject(arguments); + } + + return new ToolCallContent( + string.IsNullOrWhiteSpace(tool.Id) ? "call_" + index : tool.Id, + tool.Name.Length == 0 ? "unknown_tool" : tool.Name.ToString(), + arguments); + } + private void ApplyText( JsonElement delta, string property, @@ -1313,12 +2078,40 @@ private void ApplyText( if (!started) { started = true; - updates.Add(ModelStreamEvent.Update(startedKind, Partial())); + var slotKind = startedKind == ModelStreamEventKind.ReasoningStarted + ? ContentSlotKind.Reasoning + : ContentSlotKind.Text; + _contentOrder.Add(new ContentSlot(slotKind)); + updates.Add(ModelStreamEvent.Update( + startedKind, + Partial(), + contentIndex: ContentIndex(slotKind))); } AddCharacters(text.Length); builder.Append(text); - updates.Add(ModelStreamEvent.Update(deltaKind, Partial(), text)); + var contentKind = deltaKind == ModelStreamEventKind.ReasoningDelta + ? ContentSlotKind.Reasoning + : ContentSlotKind.Text; + updates.Add(ModelStreamEvent.Update( + deltaKind, + Partial(), + text, + ContentIndex(contentKind))); + } + + private int ContentIndex(ContentSlotKind kind, int toolIndex = -1) + { + for (var index = 0; index < _contentOrder.Count; index++) + { + var slot = _contentOrder[index]; + if (slot.Kind == kind && (kind != ContentSlotKind.Tool || slot.ToolIndex == toolIndex)) + { + return index; + } + } + + throw new InvalidDataException("A streamed content block was not registered in response order."); } private void ApplyReasoning(JsonElement delta, ICollection updates) @@ -1358,15 +2151,50 @@ private void ReadFinishReason(JsonElement choice) RequireKind(reason, JsonValueKind.String, "A model finish reason must be a string or null."); _hasFinishReason = true; - _stopReason = reason.GetString() switch + _rawStopReason = reason.GetString(); + _stopReason = _rawStopReason switch { "tool_calls" or "function_call" => ModelStopReason.ToolUse, "length" => ModelStopReason.Length, - "stop" => ModelStopReason.Stop, + "stop" or "end" => ModelStopReason.Stop, + "content_filter" => SetError("The provider stopped the response because of its content filter."), + "network_error" => SetError("The provider stopped the response because of a network error."), var unknown => SetError("The model stopped with unsupported finish reason '" + unknown + "'."), }; } + private void ReadResponseIdentity(JsonElement root) + { + ReadStableString(root, "id", ref _responseId, "response ID"); + ReadStableString(root, "model", ref _responseModel, "response model"); + } + + private static void ReadStableString( + JsonElement root, + string property, + ref string? destination, + string label) + { + if (!root.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + return; + } + + RequireKind(value, JsonValueKind.String, $"The model {label} must be a string."); + var incoming = value.GetString(); + if (string.IsNullOrWhiteSpace(incoming)) + { + throw new InvalidDataException($"The model {label} cannot be empty."); + } + + if (destination is not null && !string.Equals(destination, incoming, StringComparison.Ordinal)) + { + throw new InvalidDataException($"The model stream changed its {label}."); + } + + destination = incoming; + } + private ModelStopReason SetError(string message) { _errorMessage = message; @@ -1384,6 +2212,8 @@ private void ReadUsage(JsonElement root) var prompt = ReadNonNegativeLong(usage, "prompt_tokens"); var output = ReadNonNegativeLong(usage, "completion_tokens"); var cached = 0L; + var cacheWrite = 0L; + var reasoning = 0L; if (usage.TryGetProperty("prompt_tokens_details", out var details)) { if (details.ValueKind != JsonValueKind.Object) @@ -1392,14 +2222,35 @@ private void ReadUsage(JsonElement root) } cached = ReadNonNegativeLong(details, "cached_tokens"); + cacheWrite = ReadNonNegativeLong(details, "cache_write_tokens"); + } + + if (cached == 0) + { + cached = ReadNonNegativeLong(usage, "prompt_cache_hit_tokens"); + } + + if (usage.TryGetProperty("completion_tokens_details", out var completionDetails)) + { + if (completionDetails.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("Model completion token details must be an object."); + } + + reasoning = ReadNonNegativeLong(completionDetails, "reasoning_tokens"); } - if (cached > prompt) + if (cached + cacheWrite > prompt) { - throw new InvalidDataException("Cached prompt tokens cannot exceed total prompt tokens."); + throw new InvalidDataException("Cached and cache-written prompt tokens cannot exceed total prompt tokens."); } - _usage = new ModelUsage(prompt - cached, output, cached); + if (reasoning > output) + { + throw new InvalidDataException("Reasoning tokens cannot exceed completion tokens."); + } + + _usage = new ModelUsage(prompt - cached - cacheWrite, output, cached, cacheWrite, reasoning); } private static long ReadNonNegativeLong(JsonElement element, string property) @@ -1455,5 +2306,25 @@ private sealed class ToolBuilder public StringBuilder Arguments { get; } = new(); } + + private enum ContentSlotKind + { + Reasoning, + Text, + Tool, + } + + private sealed class ContentSlot + { + public ContentSlot(ContentSlotKind kind, int toolIndex = -1) + { + Kind = kind; + ToolIndex = toolIndex; + } + + public ContentSlotKind Kind { get; } + + public int ToolIndex { get; } + } } } diff --git a/src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj b/src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj index aa771f1..e5290d5 100644 --- a/src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj +++ b/src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj @@ -9,5 +9,6 @@ + diff --git a/src/OpenGameAgent.Providers.OpenAICompatible/packages.lock.json b/src/OpenGameAgent.Providers.OpenAICompatible/packages.lock.json index ef5d71f..775e1fc 100644 --- a/src/OpenGameAgent.Providers.OpenAICompatible/packages.lock.json +++ b/src/OpenGameAgent.Providers.OpenAICompatible/packages.lock.json @@ -69,6 +69,9 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.providertransport": { + "type": "Project" } } } diff --git a/src/OpenGameAgent.Providers.OpenRouter/OpenGameAgent.Providers.OpenRouter.csproj b/src/OpenGameAgent.Providers.OpenRouter/OpenGameAgent.Providers.OpenRouter.csproj new file mode 100644 index 0000000..1936f8e --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenRouter/OpenGameAgent.Providers.OpenRouter.csproj @@ -0,0 +1,12 @@ + + + netstandard2.1 + OpenRouter image generation and model discovery for OpenGameAgent. + + + + + + + + diff --git a/src/OpenGameAgent.Providers.OpenRouter/OpenRouterImageProvider.cs b/src/OpenGameAgent.Providers.OpenRouter/OpenRouterImageProvider.cs new file mode 100644 index 0000000..cce5fd9 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenRouter/OpenRouterImageProvider.cs @@ -0,0 +1,1222 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.Media; +using OpenGameAgent.Models; + +namespace OpenGameAgent.Providers.OpenRouter; + +public sealed class OpenRouterImageProviderOptions +{ + public OpenRouterImageProviderOptions(HttpClient httpClient) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; set; } = new("https://openrouter.ai/api/v1/images"); + + public IDictionary Headers { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public int MaxRequestBytes { get; set; } = 16_000_000; + + public int MaxResponseBytes { get; set; } = 32_000_000; + + public int MaxErrorCharacters { get; set; } = 65_536; + + public int MaxModels { get; set; } = 100_000; + + public int MaxOutputs { get; set; } = 10; + + public bool AllowInsecureHttp { get; set; } +} + +public static class OpenRouterImageProvider +{ + public const string ProviderId = "openrouter"; + public const string ApiId = "openrouter-images"; + + public static GameMediaProviderRegistration CreateRegistration( + OpenRouterImageProviderOptions options, + IGameProviderAuthentication authentication, + IReadOnlyList? initialModels = null) + { + if (authentication is null) + { + throw new ArgumentNullException(nameof(authentication)); + } + + var settings = Settings.Create(options); + var models = (initialModels ?? Array.Empty()).ToArray(); + if (models.Any(model => model is null + || !string.Equals(model.ProviderId, ProviderId, StringComparison.Ordinal) + || !string.Equals(model.Api, ApiId, StringComparison.Ordinal))) + { + throw new ArgumentException( + "Every initial image model must use the OpenRouter provider and image API.", + nameof(initialModels)); + } + + var descriptor = new GameProviderDescriptor( + ProviderId, + "OpenRouter", + settings.Endpoint, + supportsDynamicModels: true); + return new GameMediaProviderRegistration( + descriptor, + authentication, + invocation => new OpenRouterImageGenerator(settings, invocation), + Array.AsReadOnly(models), + (context, cancellationToken) => ListModelsAsync(settings, context, cancellationToken)); + } + + private static async ValueTask> ListModelsAsync( + Settings settings, + GameMediaModelRefreshContext context, + CancellationToken cancellationToken) + { + var endpoint = ResolveEndpoint(settings.Endpoint, context.Authentication?.BaseUrl); + settings.ValidateResolvedEndpoint(endpoint); + endpoint = ModelsEndpoint(endpoint); + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + ApplyHeaders(request, settings.Headers, context.Authentication, credentialAsBearer: true); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var response = await settings.HttpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await ResponseExceptionAsync(response, settings, cancellationToken).ConfigureAwait(false); + } + + using var document = await ReadJsonAsync(response, settings.MaxResponseBytes, cancellationToken) + .ConfigureAwait(false); + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("The image model directory response was invalid."); + } + + var models = new List(); + var ids = new HashSet(StringComparer.Ordinal); + var inspected = 0; + foreach (var item in data.EnumerateArray()) + { + inspected++; + if (inspected > settings.MaxModels) + { + throw new InvalidDataException("The image model directory exceeded the configured model limit."); + } + + if (item.ValueKind != JsonValueKind.Object + || !TryString(item, "id", out var id) + || id.Length > 512 + || id.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || !ids.Add(id)) + { + throw new InvalidDataException("The image model directory contained an invalid or duplicate model."); + } + + var input = GameModelInputCapabilities.None; + var output = GameModelOutputCapabilities.None; + if (item.TryGetProperty("architecture", out var architecture) + && architecture.ValueKind == JsonValueKind.Object) + { + input = ParseInputModalities(architecture); + output = ParseOutputModalities(architecture); + } + + if (!output.HasFlag(GameModelOutputCapabilities.Image)) + { + continue; + } + + if (input == GameModelInputCapabilities.None) + { + input = GameModelInputCapabilities.Text; + } + + var metadata = new Dictionary(StringComparer.Ordinal); + if (TryString(item, "description", out var description) && description.Length <= 16_384) + { + metadata["description"] = description; + } + + if (item.TryGetProperty("supports_streaming", out var supportsStreaming) + && supportsStreaming.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + metadata["supportsStreaming"] = supportsStreaming.GetBoolean() ? "true" : "false"; + } + + if (item.TryGetProperty("supported_parameters", out var supportedParameters) + && supportedParameters.ValueKind == JsonValueKind.Object) + { + var raw = supportedParameters.GetRawText(); + if (raw.Length <= 16_384) + { + metadata["supportedParameters"] = raw; + } + } + + var name = TryString(item, "name", out var displayName) + && displayName.Length <= 512 + && displayName.IndexOfAny(new[] { '\r', '\n', '\0' }) < 0 + ? displayName + : id; + models.Add(new GameModelDescriptor( + ProviderId, + id, + name, + inputCapabilities: input, + outputCapabilities: GameModelOutputCapabilities.Image, + metadata: metadata, + api: ApiId, + baseUrl: settings.Endpoint)); + } + + return Array.AsReadOnly(models.OrderBy(model => model.ModelId, StringComparer.Ordinal).ToArray()); + } + + private static GameModelInputCapabilities ParseInputModalities(JsonElement architecture) + { + if (!architecture.TryGetProperty("input_modalities", out var values) + || values.ValueKind != JsonValueKind.Array) + { + return GameModelInputCapabilities.None; + } + + var result = GameModelInputCapabilities.None; + foreach (var value in values.EnumerateArray()) + { + if (value.ValueKind != JsonValueKind.String) + { + continue; + } + + result |= value.GetString() switch + { + "text" => GameModelInputCapabilities.Text, + "image" => GameModelInputCapabilities.Image, + "audio" => GameModelInputCapabilities.Audio, + "video" => GameModelInputCapabilities.Video, + _ => GameModelInputCapabilities.None, + }; + } + + return result; + } + + private static GameModelOutputCapabilities ParseOutputModalities(JsonElement architecture) + { + if (!architecture.TryGetProperty("output_modalities", out var values) + || values.ValueKind != JsonValueKind.Array) + { + return GameModelOutputCapabilities.None; + } + + var result = GameModelOutputCapabilities.None; + foreach (var value in values.EnumerateArray()) + { + if (value.ValueKind != JsonValueKind.String) + { + continue; + } + + result |= value.GetString() switch + { + "text" => GameModelOutputCapabilities.Text, + "image" => GameModelOutputCapabilities.Image, + "audio" => GameModelOutputCapabilities.Audio, + "video" => GameModelOutputCapabilities.Video, + _ => GameModelOutputCapabilities.None, + }; + } + + return result; + } + + private static Uri ResolveEndpoint(Uri configured, Uri? authentication) + { + var endpoint = authentication ?? configured; + var path = endpoint.AbsolutePath.TrimEnd('/'); + if (!path.EndsWith("/images", StringComparison.OrdinalIgnoreCase)) + { + var builder = new UriBuilder(endpoint) + { + Path = path + "/images", + }; + endpoint = builder.Uri; + } + + return endpoint; + } + + private static Uri ModelsEndpoint(Uri imageEndpoint) + { + var builder = new UriBuilder(imageEndpoint) + { + Path = imageEndpoint.AbsolutePath.TrimEnd('/') + "/models", + }; + return builder.Uri; + } + + private static void ApplyHeaders( + HttpRequestMessage request, + IReadOnlyDictionary configured, + GameProviderAuthResolution? authentication, + bool credentialAsBearer) + { + var headers = new Dictionary(configured, StringComparer.OrdinalIgnoreCase); + var suppressed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pair in authentication?.Headers ?? new Dictionary()) + { + if (pair.Value is null) + { + headers.Remove(pair.Key); + suppressed.Add(pair.Key); + } + else + { + headers[pair.Key] = pair.Value; + suppressed.Remove(pair.Key); + } + } + + if (credentialAsBearer + && authentication?.Credential is { } credential + && !headers.ContainsKey("Authorization") + && !suppressed.Contains("Authorization")) + { + headers["Authorization"] = "Bearer " + credential.Secret; + } + + foreach (var pair in headers) + { + if (pair.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) + || pair.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) + || pair.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) + || !request.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) + { + throw new InvalidOperationException("The image provider configuration contained an invalid header."); + } + } + } + + private static async ValueTask ResponseExceptionAsync( + HttpResponseMessage response, + Settings settings, + CancellationToken cancellationToken) + { + var body = await ReadTextAsync(response.Content, settings.MaxErrorCharacters, cancellationToken) + .ConfigureAwait(false); + var code = TryProviderErrorCode(body); + var suffix = code is null ? string.Empty : " (" + code + ")"; + return new InvalidOperationException( + $"The image provider returned HTTP {(int)response.StatusCode}.{suffix}"); + } + + private static string? TryProviderErrorCode(string body) + { + try + { + using var document = JsonDocument.Parse(body, new JsonDocumentOptions { MaxDepth = 32 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object) + { + if (TryString(error, "code", out var nestedCode)) + { + return SafeErrorCode(nestedCode); + } + + if (TryString(error, "type", out var nestedType)) + { + return SafeErrorCode(nestedType); + } + } + + return TryString(root, "code", out var code) ? SafeErrorCode(code) : null; + } + catch (JsonException) + { + return null; + } + } + + private static string? SafeErrorCode(string value) + { + if (value.Length is < 1 or > 256 + || value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + return null; + } + + return value; + } + + private static async ValueTask ReadJsonAsync( + HttpResponseMessage response, + int maximumBytes, + CancellationToken cancellationToken) + { + using var stream = await ReadResponseStreamAsync(response.Content, cancellationToken).ConfigureAwait(false); + var bytes = await ReadBytesAsync(stream, maximumBytes, cancellationToken).ConfigureAwait(false); + try + { + return JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 128 }); + } + catch (JsonException exception) + { + throw new InvalidDataException("The image provider returned invalid JSON.", exception); + } + } + + private static async ValueTask ReadTextAsync( + HttpContent content, + int maximumCharacters, + CancellationToken cancellationToken) + { + using var stream = await ReadResponseStreamAsync(content, cancellationToken).ConfigureAwait(false); + var bytes = await ReadBytesAsync(stream, checked(maximumCharacters * 4), cancellationToken) + .ConfigureAwait(false); + var value = new UTF8Encoding(false, true).GetString(bytes); + return value.Length <= maximumCharacters ? value : value.Substring(0, maximumCharacters); + } + + private static async Task ReadResponseStreamAsync( + HttpContent content, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var streamTask = content.ReadAsStreamAsync(); + if (!streamTask.IsCompleted) + { + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + state => ((TaskCompletionSource)state!).TrySetResult(true), + canceled); + if (streamTask != await Task.WhenAny(streamTask, canceled.Task).ConfigureAwait(false)) + { + _ = streamTask.ContinueWith( + completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + else + { + _ = completed.Exception; + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw new OperationCanceledException(cancellationToken); + } + } + + var stream = await streamTask.ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + stream.Dispose(); + throw new OperationCanceledException(cancellationToken); + } + + return stream; + } + + private static async ValueTask ReadBytesAsync( + Stream stream, + int maximumBytes, + CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(stream.Dispose); + using var buffer = new MemoryStream(); + var rented = ArrayPool.Shared.Rent(8192); + try + { + while (true) + { + var read = await stream.ReadAsync(rented, 0, rented.Length, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return buffer.ToArray(); + } + + if (buffer.Length + read > maximumBytes) + { + throw new InvalidDataException("The image provider response exceeded the configured size limit."); + } + + buffer.Write(rented, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static bool TryString(JsonElement value, string name, out string result) + { + if (value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && property.GetString() is { Length: > 0 } text) + { + result = text; + return true; + } + + result = string.Empty; + return false; + } + + private sealed class OpenRouterImageGenerator : IGameMediaGenerator + { + private readonly Settings _settings; + private readonly GameMediaGenerationInvocation _invocation; + + public OpenRouterImageGenerator(Settings settings, GameMediaGenerationInvocation invocation) + { + _settings = settings; + _invocation = invocation ?? throw new ArgumentNullException(nameof(invocation)); + if (!string.Equals(invocation.Model.ProviderId, ProviderId, StringComparison.Ordinal) + || !string.Equals(invocation.Model.Api, ApiId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The selected model is not an OpenRouter image model."); + } + } + + public async ValueTask GenerateAsync( + GameMediaGenerationRequest request, + GameMediaProgressHandler? progress, + CancellationToken cancellationToken) + { + if (request.Kind != GameMediaKind.Image) + { + throw new InvalidOperationException("This provider only supports image generation."); + } + + if (string.IsNullOrWhiteSpace(request.Prompt)) + { + throw new InvalidOperationException("The image provider requires a non-empty prompt."); + } + + var endpoint = ResolveEndpoint(_settings.Endpoint, _invocation.Endpoint); + _settings.ValidateResolvedEndpoint(endpoint); + var body = BuildRequest(request, _invocation.Model.ModelId, out var streaming); + if (body.Length > _settings.MaxRequestBytes) + { + throw new InvalidDataException("The image generation request exceeded the configured size limit."); + } + + using var message = new HttpRequestMessage(HttpMethod.Post, endpoint); + ApplyHeaders(message, _settings.Headers, _invocation.Authentication, credentialAsBearer: true); + foreach (var pair in _invocation.Headers) + { + if (pair.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) + || pair.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) + || pair.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The image model contained a reserved header."); + } + + message.Headers.Remove(pair.Key); + if (!message.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) + { + throw new InvalidOperationException("The image model contained an invalid header."); + } + } + + message.Content = new ByteArrayContent(body); + message.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + using var response = await _settings.HttpClient.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await ResponseExceptionAsync(response, _settings, cancellationToken).ConfigureAwait(false); + } + + var requestId = RequestId(response); + return streaming + ? await ReadStreamingResultAsync(response, progress, requestId, cancellationToken).ConfigureAwait(false) + : await ReadBufferedResultAsync(response, requestId, cancellationToken).ConfigureAwait(false); + } + + private byte[] BuildRequest( + GameMediaGenerationRequest request, + string model, + out bool streaming) + { + using var parameters = JsonDocument.Parse(request.ParametersJson, new JsonDocumentOptions { MaxDepth = 128 }); + if (parameters.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Image generation parameters must be a JSON object."); + } + + streaming = false; + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteString("model", model); + writer.WriteString("prompt", request.Prompt); + foreach (var property in parameters.RootElement.EnumerateObject()) + { + if (property.NameEquals("model") + || property.NameEquals("prompt") + || property.NameEquals("input_references")) + { + throw new InvalidOperationException( + $"Image generation parameter '{property.Name}' is reserved by the provider adapter."); + } + + ValidateParameter(property); + if (property.NameEquals("stream")) + { + streaming = property.Value.GetBoolean(); + } + + property.WriteTo(writer); + } + + if (request.Sources.Count > 0) + { + writer.WritePropertyName("input_references"); + writer.WriteStartArray(); + foreach (var source in request.Sources) + { + ValidateReference(source); + writer.WriteStartObject(); + writer.WriteString("type", "image_url"); + writer.WritePropertyName("image_url"); + writer.WriteStartObject(); + writer.WriteString("url", source.Uri); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + return buffer.ToArray(); + } + + private static void ValidateParameter(JsonProperty property) + { + if (property.NameEquals("stream") && property.Value.ValueKind is not JsonValueKind.True and not JsonValueKind.False) + { + throw new InvalidOperationException("The image generation stream parameter must be boolean."); + } + + if (property.NameEquals("n") + && (!property.Value.TryGetInt32(out var count) || count is < 1 or > 10)) + { + throw new InvalidOperationException("The image generation count must be between 1 and 10."); + } + + if (property.NameEquals("output_compression") + && (!property.Value.TryGetInt32(out var compression) || compression is < 0 or > 100)) + { + throw new InvalidOperationException("Image output compression must be between 0 and 100."); + } + } + + private static void ValidateReference(ResourceContent source) + { + if (!IsImageMediaType(source.MediaType)) + { + throw new InvalidOperationException("OpenRouter image references must use an image media type."); + } + + if (Uri.TryCreate(source.Uri, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + if (uri.UserInfo.Length > 0) + { + throw new InvalidOperationException("Image reference URLs cannot contain embedded credentials."); + } + + return; + } + + var prefix = "data:" + source.MediaType + ";base64,"; + if (!source.Uri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Image references must be HTTP(S) URLs or matching base64 data URLs."); + } + + ValidateBase64(source.Uri.Substring(prefix.Length)); + } + + private async ValueTask ReadBufferedResultAsync( + HttpResponseMessage response, + string? requestId, + CancellationToken cancellationToken) + { + using var document = await ReadJsonAsync(response, _settings.MaxResponseBytes, cancellationToken) + .ConfigureAwait(false); + var outputs = ParseBufferedOutputs(document.RootElement); + return new GameMediaGenerationResult( + outputs, + Metadata(document.RootElement), + requestId); + } + + private async ValueTask ReadStreamingResultAsync( + HttpResponseMessage response, + GameMediaProgressHandler? progress, + string? requestId, + CancellationToken cancellationToken) + { + if (!string.Equals( + response.Content.Headers.ContentType?.MediaType, + "text/event-stream", + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("The image provider streaming response must use text/event-stream."); + } + + using var source = await ReadResponseStreamAsync(response.Content, cancellationToken).ConfigureAwait(false); + using var stream = new BoundedReadStream(source, _settings.MaxResponseBytes); + using var registration = cancellationToken.Register(stream.Dispose); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true, 4096, leaveOpen: false); + var outputs = new List(); + JsonElement? usage = null; + JsonElement? created = null; + var done = false; + var data = new StringBuilder(); + while (!done) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line is null) + { + break; + } + + if (line.Length == 0) + { + if (data.Length > 0) + { + var value = data.ToString(); + data.Clear(); + if (value == "[DONE]") + { + done = true; + continue; + } + + var eventMetadata = await ProcessStreamingEventAsync( + value, + outputs, + progress, + cancellationToken).ConfigureAwait(false); + if (eventMetadata?.Usage is { } eventUsage) + { + usage = eventUsage; + } + + if (eventMetadata?.Created is { } eventCreated) + { + created = eventCreated; + } + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + if (data.Length > 0) + { + data.Append('\n'); + } + + var value = line.Substring(5); + data.Append(value.StartsWith(" ", StringComparison.Ordinal) ? value.Substring(1) : value); + } + } + + if (data.Length > 0) + { + if (data.ToString() == "[DONE]") + { + done = true; + } + else + { + var eventMetadata = await ProcessStreamingEventAsync( + data.ToString(), + outputs, + progress, + cancellationToken).ConfigureAwait(false); + if (eventMetadata?.Usage is { } eventUsage) + { + usage = eventUsage; + } + + if (eventMetadata?.Created is { } eventCreated) + { + created = eventCreated; + } + } + } + + if (!done) + { + throw new InvalidDataException("The image provider stream ended without its terminal marker."); + } + + if (outputs.Count == 0) + { + throw new InvalidDataException("The image provider stream ended without a completed image."); + } + + return new GameMediaGenerationResult( + outputs, + StreamingMetadata(created, usage), + requestId); + } + + private async ValueTask ProcessStreamingEventAsync( + string value, + ICollection outputs, + GameMediaProgressHandler? progress, + CancellationToken cancellationToken) + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + var root = document.RootElement; + if (!TryString(root, "type", out var type)) + { + throw new InvalidDataException("The image provider stream returned an event without a type."); + } + + if (type == "image_generation.partial_image") + { + if (progress is not null) + { + var index = root.TryGetProperty("partial_image_index", out var indexValue) + && indexValue.TryGetInt32(out var parsed) + ? parsed + : 0; + if (index < 0 || index >= _settings.MaxOutputs) + { + throw new InvalidDataException("The image provider returned an invalid partial-image index."); + } + + var preview = TryString(root, "b64_json", out _) + ? ParseImage(root, index) + : null; + await progress( + new GameMediaGenerationProgress( + "partial_image", + detailsJson: JsonSerializer.Serialize(new { index }), + preview: preview), + cancellationToken).ConfigureAwait(false); + } + + return null; + } + + if (type == "image_generation.completed") + { + if (outputs.Count >= _settings.MaxOutputs) + { + throw new InvalidDataException("The image provider returned too many outputs."); + } + + outputs.Add(ParseImage(root, outputs.Count)); + var usage = root.TryGetProperty("usage", out var usageValue) ? usageValue.Clone() : default(JsonElement?); + var created = root.TryGetProperty("created", out var createdValue) + ? createdValue.Clone() + : default(JsonElement?); + return new StreamingEventMetadata(created, usage); + } + + if (type == "error") + { + throw new InvalidOperationException(BoundProviderError(root)); + } + + throw new InvalidDataException("The image provider stream returned an unsupported event type."); + } + + private static string StreamingMetadata(JsonElement? created, JsonElement? usage) + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + if (created is { } createdValue) + { + writer.WritePropertyName("created"); + createdValue.WriteTo(writer); + } + + if (usage is { } usageValue) + { + writer.WritePropertyName("usage"); + usageValue.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private sealed class StreamingEventMetadata + { + public StreamingEventMetadata(JsonElement? created, JsonElement? usage) + { + Created = created; + Usage = usage; + } + + public JsonElement? Created { get; } + + public JsonElement? Usage { get; } + } + + private IReadOnlyList ParseBufferedOutputs(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("data", out var data) + || data.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("The image provider returned an invalid result."); + } + + var outputs = new List(); + foreach (var item in data.EnumerateArray()) + { + if (outputs.Count >= _settings.MaxOutputs) + { + throw new InvalidDataException("The image provider returned too many outputs."); + } + + outputs.Add(ParseImage(item, outputs.Count)); + } + + if (outputs.Count == 0) + { + throw new InvalidDataException("The image provider did not return an image."); + } + + return Array.AsReadOnly(outputs.ToArray()); + } + + private static ResourceContent ParseImage(JsonElement value, int index) + { + if (value.ValueKind != JsonValueKind.Object + || !TryString(value, "b64_json", out var data)) + { + throw new InvalidDataException("The image provider returned an invalid image."); + } + + ValidateBase64(data); + var mediaType = TryString(value, "media_type", out var reported) ? reported : "image/png"; + if (!IsImageMediaType(mediaType)) + { + throw new InvalidDataException("The image provider returned an invalid media type."); + } + + return new ResourceContent( + "data:" + mediaType + ";base64," + data, + mediaType, + "image-" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private static void ValidateBase64(string value) + { + if (value.Length == 0 + || value.Length > 32_000_000 + || value.Any(char.IsWhiteSpace)) + { + throw new InvalidDataException("The image payload was empty, oversized, or invalid base64."); + } + + var maximumBytes = checked((value.Length / 4 + 1) * 3); + var rented = ArrayPool.Shared.Rent(maximumBytes); + try + { + if (!Convert.TryFromBase64String(value, rented, out _)) + { + throw new InvalidDataException("The image payload was not valid base64."); + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static bool IsImageMediaType(string value) => + value.Length <= 512 + && value.IndexOfAny(new[] { '\r', '\n', '\0', ';' }) < 0 + && MediaTypeHeaderValue.TryParse(value, out var parsed) + && parsed.Parameters.Count == 0 + && parsed.MediaType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true; + + private static string Metadata(JsonElement root) + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + if (root.TryGetProperty("created", out var created)) + { + writer.WritePropertyName("created"); + created.WriteTo(writer); + } + + if (root.TryGetProperty("usage", out var usage)) + { + writer.WritePropertyName("usage"); + usage.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private static string BoundProviderError(JsonElement root) + { + if (root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object) + { + if (TryString(error, "code", out var code) && SafeErrorCode(code) is { } safeCode) + { + return "The image provider stream failed (" + safeCode + ")."; + } + + if (TryString(error, "type", out var type) && SafeErrorCode(type) is { } safeType) + { + return "The image provider stream failed (" + safeType + ")."; + } + } + + return "The image provider stream failed."; + } + + private static string? RequestId(HttpResponseMessage response) + { + foreach (var name in new[] { "x-request-id", "x-openrouter-request-id" }) + { + if (response.Headers.TryGetValues(name, out var values)) + { + var value = values.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value) + && value.Length <= 512 + && value.IndexOfAny(new[] { '\r', '\n', '\0' }) < 0) + { + return value; + } + } + } + + return null; + } + } + + private sealed class Settings + { + private Settings( + HttpClient httpClient, + Uri endpoint, + IReadOnlyDictionary headers, + int maxRequestBytes, + int maxResponseBytes, + int maxErrorCharacters, + int maxModels, + int maxOutputs, + bool allowInsecureHttp) + { + HttpClient = httpClient; + Endpoint = endpoint; + Headers = headers; + MaxRequestBytes = maxRequestBytes; + MaxResponseBytes = maxResponseBytes; + MaxErrorCharacters = maxErrorCharacters; + MaxModels = maxModels; + MaxOutputs = maxOutputs; + AllowInsecureHttp = allowInsecureHttp; + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public IReadOnlyDictionary Headers { get; } + + public int MaxRequestBytes { get; } + + public int MaxResponseBytes { get; } + + public int MaxErrorCharacters { get; } + + public int MaxModels { get; } + + public int MaxOutputs { get; } + + public bool AllowInsecureHttp { get; } + + public static Settings Create(OpenRouterImageProviderOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + ValidateEndpoint(options.Endpoint, options.AllowInsecureHttp); + if (options.MaxRequestBytes is < 2 or > 100_000_000 + || options.MaxResponseBytes is < 2 or > 100_000_000 + || options.MaxErrorCharacters is < 1 or > 65_536 + || options.MaxModels is < 1 or > 100_000 + || options.MaxOutputs is < 1 or > 10) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + if (options.Headers.Count > 256) + { + throw new ArgumentException("The image provider has too many headers.", nameof(options)); + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in options.Headers) + { + if (string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.Length > 256 + || pair.Value is null + || pair.Value.Length > 65_536 + || pair.Key.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || pair.Value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || !headers.TryAdd(pair.Key, pair.Value)) + { + throw new ArgumentException("The image provider contains an invalid header.", nameof(options)); + } + } + + return new Settings( + options.HttpClient, + options.Endpoint, + new ReadOnlyDictionary(headers), + options.MaxRequestBytes, + options.MaxResponseBytes, + options.MaxErrorCharacters, + options.MaxModels, + options.MaxOutputs, + options.AllowInsecureHttp); + } + + public void ValidateResolvedEndpoint(Uri endpoint) => + ValidateEndpoint(endpoint, AllowInsecureHttp); + + private static void ValidateEndpoint(Uri endpoint, bool allowInsecureHttp) + { + if (endpoint is null + || !endpoint.IsAbsoluteUri + || endpoint.UserInfo.Length > 0 + || endpoint.Fragment.Length > 0 + || (endpoint.Scheme != Uri.UriSchemeHttps + && (endpoint.Scheme != Uri.UriSchemeHttp || !endpoint.IsLoopback && !allowInsecureHttp))) + { + throw new ArgumentException( + "The image provider endpoint must be an absolute HTTPS URL without credentials or a fragment.", + nameof(endpoint)); + } + } + } + + private sealed class BoundedReadStream : Stream + { + private readonly Stream _inner; + private readonly long _maximumBytes; + private long _read; + + public BoundedReadStream(Stream inner, long maximumBytes) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _maximumBytes = maximumBytes; + } + + public override bool CanRead => _inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => + Record(_inner.Read(buffer, offset, count)); + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) => + Record(await _inner.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false)); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + private int Record(int count) + { + _read = checked(_read + count); + if (_read > _maximumBytes) + { + throw new InvalidDataException("The image provider response exceeded the configured size limit."); + } + + return count; + } + } +} diff --git a/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json b/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json new file mode 100644 index 0000000..7ef1890 --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json @@ -0,0 +1,95 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.media": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.Remote/ModelProviderProxyServer.cs b/src/OpenGameAgent.Providers.Remote/ModelProviderProxyServer.cs new file mode 100644 index 0000000..c7fbc86 --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/ModelProviderProxyServer.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Remote; + +public sealed class ModelProviderProxyServer +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + private readonly IModelProvider _provider; + private readonly ServerSettings _settings; + + public ModelProviderProxyServer( + IModelProvider provider, + ModelProviderProxyServerOptions? options = null) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _settings = ServerSettings.Validate(options ?? new ModelProviderProxyServerOptions()); + } + + public async Task HandleAsync( + HttpRequestMessage request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + ModelRequest? modelRequest = null; + string? preflightError = null; + if (request.Method != HttpMethod.Post) + { + preflightError = "The remote provider proxy only accepts POST requests."; + } + else if (!Authenticate(request)) + { + preflightError = "Unauthorized remote provider request."; + } + else if (request.Content is null + || !string.Equals( + request.Content.Headers.ContentType?.MediaType, + "application/json", + StringComparison.OrdinalIgnoreCase)) + { + preflightError = "The remote provider request must use application/json."; + } + else + { + try + { + var requestBody = await ReadRequestBodyAsync(request.Content, cancellationToken).ConfigureAwait(false); + modelRequest = ProxyWire.ParseRequest(requestBody, _settings.MaximumJsonDepth); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is InvalidDataException or IOException or DecoderFallbackException) + { + preflightError = exception.Message; + } + } + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ProxySseContent( + (stream, token) => WriteResponseAsync(stream, modelRequest, preflightError, token), + cancellationToken), + }; + response.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true }; + return response; + } + + public Task WriteAsync( + Stream output, + ModelRequest request, + CancellationToken cancellationToken = default) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return WriteResponseAsync(output, request, null, cancellationToken); + } + + private async Task WriteResponseAsync( + Stream output, + ModelRequest? request, + string? preflightError, + CancellationToken cancellationToken) + { + var writer = new ProxySseWriter( + output, + _settings.MaximumEventBytes, + _settings.MaximumResponseBytes); + var setupWritten = false; + ModelResponse? lastPartial = null; + try + { + if (preflightError is not null || request is null) + { + await writer.WriteAsync(Setup(DefaultPartial(request)), cancellationToken).ConfigureAwait(false); + setupWritten = true; + await writer.WriteAsync( + Terminal(ErrorResponse(null, preflightError ?? "The remote provider request is invalid.", request)), + cancellationToken).ConfigureAwait(false); + return; + } + + var decoder = new RemoteStreamDecoder(); + WireFrame? pendingTerminal = null; + var eventCount = 0; + await using var enumerator = _provider + .StreamAsync(request, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + while (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + eventCount++; + if (eventCount > _settings.MaximumEvents) + { + throw new InvalidDataException("The upstream model provider exceeded the configured event limit."); + } + + var modelEvent = enumerator.Current + ?? throw new InvalidDataException("The upstream model provider emitted a null event."); + if (!setupWritten) + { + if (modelEvent.Kind != ModelStreamEventKind.Started || modelEvent.Partial is null) + { + throw new InvalidDataException("The upstream model provider must begin with a start event."); + } + + var setup = Setup(modelEvent.Partial); + decoder.Decode(setup); + await writer.WriteAsync(setup, cancellationToken).ConfigureAwait(false); + setupWritten = true; + } + + var frame = Frame(modelEvent); + var decoded = decoder.Decode(frame); + ValidateRoundTrip(modelEvent, decoded); + if (modelEvent.IsTerminal) + { + if (pendingTerminal is not null) + { + throw new InvalidDataException("The upstream model provider emitted more than one terminal event."); + } + + pendingTerminal = frame; + continue; + } + + if (pendingTerminal is not null) + { + throw new InvalidDataException("The upstream model provider emitted an event after its terminal event."); + } + + lastPartial = modelEvent.Partial; + await writer.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + } + + if (!setupWritten) + { + throw new InvalidDataException("The upstream model provider ended before its start event."); + } + + if (pendingTerminal is null) + { + throw new InvalidDataException("The upstream model provider ended without a terminal event."); + } + + await writer.WriteAsync(pendingTerminal, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + if (!setupWritten) + { + await writer.WriteAsync(Setup(DefaultPartial(request)), cancellationToken).ConfigureAwait(false); + } + + var error = ErrorResponse(lastPartial, exception.Message, request); + await writer.WriteAsync(Terminal(error), cancellationToken).ConfigureAwait(false); + } + } + + private static WireFrame Setup(ModelResponse partial) => new() + { + Type = ProxyWire.SetupFrame, + Version = ProxyWire.Version, + Response = ProxyWire.Response(partial), + }; + + private static WireFrame Terminal(ModelResponse response) => new() + { + Type = ProxyWire.TerminalFrame, + Response = ProxyWire.Response(response), + }; + + private static WireFrame Frame(ModelStreamEvent modelEvent) + { + if (modelEvent.IsTerminal) + { + return Terminal(modelEvent.Response + ?? throw new InvalidDataException("An upstream terminal event requires a response.")); + } + + var partial = modelEvent.Partial + ?? throw new InvalidDataException("An upstream update event requires a partial response."); + if (partial.StopReason != ModelStopReason.Pending) + { + throw new InvalidDataException("An upstream update event must contain a pending partial response."); + } + + AgentContent? content = null; + if (modelEvent.Kind != ModelStreamEventKind.Started) + { + if (modelEvent.ContentIndex < 0 || modelEvent.ContentIndex >= partial.Content.Count) + { + throw new InvalidDataException("An upstream content event contains an invalid content index."); + } + + var partialContent = partial.Content[modelEvent.ContentIndex]; + if (modelEvent.Kind is ModelStreamEventKind.TextStarted + or ModelStreamEventKind.ReasoningStarted + or ModelStreamEventKind.ToolCallStarted + or ModelStreamEventKind.ToolCallDelta + or ModelStreamEventKind.TextEnded + or ModelStreamEventKind.ReasoningEnded + or ModelStreamEventKind.ToolCallEnded) + { + content = partialContent; + } + + if (modelEvent.Kind is ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded) + { + var actual = partialContent switch + { + TextContent text => text.Text, + ReasoningContent reasoning => reasoning.Text, + _ => throw new InvalidDataException("An upstream content-end event has the wrong content type."), + }; + if (!string.Equals(modelEvent.Content, actual, StringComparison.Ordinal)) + { + throw new InvalidDataException("An upstream content-end event disagrees with its partial response."); + } + } + else if (modelEvent.Kind == ModelStreamEventKind.ToolCallEnded + && (modelEvent.ToolCall is null + || !ProxyWire.ContentEquals(modelEvent.ToolCall, partialContent))) + { + throw new InvalidDataException("An upstream tool-call end event disagrees with its partial response."); + } + } + + return new WireFrame + { + Type = ProxyWire.EventFrame, + Kind = (int)modelEvent.Kind, + ContentIndex = modelEvent.Kind == ModelStreamEventKind.Started ? null : modelEvent.ContentIndex, + Delta = modelEvent.Delta, + ToolCallId = modelEvent.ToolCallId, + ToolName = modelEvent.ToolName, + Content = content is null ? null : ProxyWire.Content(content), + }; + } + + private static void ValidateRoundTrip(ModelStreamEvent expected, ModelStreamEvent? actual) + { + if (actual is null + || expected.Kind != actual.Kind + || expected.ContentIndex != actual.ContentIndex + || !string.Equals(expected.Delta, actual.Delta, StringComparison.Ordinal) + || !string.Equals(expected.Content, actual.Content, StringComparison.Ordinal) + || !string.Equals(expected.ToolCallId, actual.ToolCallId, StringComparison.Ordinal) + || !string.Equals(expected.ToolName, actual.ToolName, StringComparison.Ordinal)) + { + throw new InvalidDataException("An upstream model event cannot be represented by the proxy protocol."); + } + + if (expected.ToolCall is not null + && (actual.ToolCall is null || !ProxyWire.ContentEquals(expected.ToolCall, actual.ToolCall))) + { + throw new InvalidDataException("An upstream completed tool call cannot be represented by the proxy protocol."); + } + + if (expected.Partial is not null + && (actual.Partial is null + || !ProxyWire.ContentSequenceEquals(expected.Partial.Content, actual.Partial.Content))) + { + throw new InvalidDataException("An upstream partial response cannot be reconstructed without loss."); + } + + if (expected.Response is not null && actual.Response is not null) + { + ValidateResponseRoundTrip(expected.Response, actual.Response); + } + } + + private static void ValidateResponseRoundTrip(ModelResponse expected, ModelResponse actual) + { + if (!ProxyWire.ResponseEquals(expected, actual)) + { + throw new InvalidDataException("An upstream terminal response cannot be represented without loss."); + } + } + + private static ModelResponse DefaultPartial(ModelRequest? request) => new( + Array.Empty(), + ModelStopReason.Pending, + responseModel: request?.Model); + + private static ModelResponse ErrorResponse( + ModelResponse? partial, + string message, + ModelRequest? request) + { + var boundedMessage = string.IsNullOrWhiteSpace(message) + ? "Remote model provider proxy failure." + : message.Length <= 4_096 ? message : message.Substring(0, 4_096); + return new ModelResponse( + partial?.Content ?? Array.Empty(), + ModelStopReason.Error, + partial?.Usage, + boundedMessage, + partial?.Provider, + partial?.Api, + partial?.ResponseModel ?? request?.Model, + partial?.ResponseId, + partial?.RawStopReason, + partial?.EndTurn, + partial?.Diagnostics); + } + + private bool Authenticate(HttpRequestMessage request) + { + if (_settings.ApiKey is null) + { + return true; + } + + if (!request.Headers.TryGetValues(_settings.ApiKeyHeader, out var values)) + { + return false; + } + + var supplied = values.SingleOrDefault(); + if (supplied is null) + { + return false; + } + + var expected = string.IsNullOrEmpty(_settings.ApiKeyScheme) + ? _settings.ApiKey + : _settings.ApiKeyScheme + " " + _settings.ApiKey; + return FixedTimeEquals(supplied, expected); + } + + private async Task ReadRequestBodyAsync(HttpContent content, CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is > 0 + && content.Headers.ContentLength > _settings.MaximumRequestBytes) + { + throw new InvalidDataException("The remote provider request exceeded the configured size limit."); + } + + using var source = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(source.Dispose); + using var destination = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + int read; + try + { + read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + if (read == 0) + { + break; + } + + if (destination.Length + read > _settings.MaximumRequestBytes) + { + throw new InvalidDataException("The remote provider request exceeded the configured size limit."); + } + + destination.Write(buffer, 0, read); + } + + return StrictUtf8.GetString(destination.ToArray()); + } + + private static bool FixedTimeEquals(string supplied, string expected) + { + var length = Math.Max(supplied.Length, expected.Length); + var difference = supplied.Length ^ expected.Length; + for (var index = 0; index < length; index++) + { + var left = index < supplied.Length ? supplied[index] : '\0'; + var right = index < expected.Length ? expected[index] : '\0'; + difference |= left ^ right; + } + + return difference == 0; + } + + private sealed class ServerSettings + { + private ServerSettings(ModelProviderProxyServerOptions options) + { + ApiKey = options.ApiKey; + ApiKeyHeader = options.ApiKeyHeader; + ApiKeyScheme = options.ApiKeyScheme; + MaximumRequestBytes = options.MaximumRequestBytes; + MaximumResponseBytes = options.MaximumResponseBytes; + MaximumEventBytes = options.MaximumEventBytes; + MaximumEvents = options.MaximumEvents; + MaximumJsonDepth = options.MaximumJsonDepth; + } + + public string? ApiKey { get; } + public string ApiKeyHeader { get; } + public string ApiKeyScheme { get; } + public int MaximumRequestBytes { get; } + public int MaximumResponseBytes { get; } + public int MaximumEventBytes { get; } + public int MaximumEvents { get; } + public int MaximumJsonDepth { get; } + + public static ServerSettings Validate(ModelProviderProxyServerOptions options) + { + if (options.MaximumRequestBytes < 2 || options.MaximumRequestBytes > 100_000_000 + || options.MaximumResponseBytes < 2 || options.MaximumResponseBytes > 100_000_000 + || options.MaximumEventBytes < 2 || options.MaximumEventBytes > 100_000_000 + || options.MaximumEvents < 2 || options.MaximumEvents > 10_000_000 + || options.MaximumJsonDepth < 1 || options.MaximumJsonDepth > 1_024) + { + throw new ArgumentOutOfRangeException(nameof(options), "Remote provider proxy limits are invalid."); + } + + RemoteModelProviderOptions.ValidateCredential(options.ApiKey, nameof(options.ApiKey), 65_536); + RemoteModelProviderOptions.ValidateCredential(options.ApiKeyHeader, nameof(options.ApiKeyHeader), 256); + RemoteModelProviderOptions.ValidateCredential( + options.ApiKeyScheme, + nameof(options.ApiKeyScheme), + 256, + allowEmpty: true); + if (!RemoteModelProviderOptions.IsValidHeaderName(options.ApiKeyHeader)) + { + throw new ArgumentException("A valid server API key header name is required.", nameof(options)); + } + + return new ServerSettings(options); + } + } +} + +internal sealed class ProxySseWriter +{ + private static readonly Encoding Utf8 = new UTF8Encoding(false); + private readonly Stream _output; + private readonly int _maximumEventBytes; + private readonly int _maximumResponseBytes; + private int _written; + + public ProxySseWriter(Stream output, int maximumEventBytes, int maximumResponseBytes) + { + _output = output; + _maximumEventBytes = maximumEventBytes; + _maximumResponseBytes = maximumResponseBytes; + } + + public async Task WriteAsync(WireFrame frame, CancellationToken cancellationToken) + { + var json = ProxyWire.SerializeFrame(frame); + var payload = Utf8.GetBytes("data:" + json + "\n\n"); + if (payload.Length > _maximumEventBytes) + { + throw new InvalidDataException("A remote provider proxy event exceeded the configured size limit."); + } + + _written = checked(_written + payload.Length); + if (_written > _maximumResponseBytes) + { + throw new InvalidDataException("The remote provider proxy response exceeded the configured size limit."); + } + + await _output.WriteAsync(payload, 0, payload.Length, cancellationToken).ConfigureAwait(false); + await _output.FlushAsync(cancellationToken).ConfigureAwait(false); + } +} + +internal sealed class ProxySseContent : HttpContent +{ + private readonly Func _write; + private readonly CancellationTokenSource _cancellation; + private int _disposed; + + public ProxySseContent( + Func write, + CancellationToken cancellationToken) + { + _write = write; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Headers.ContentType = new MediaTypeHeaderValue("text/event-stream") + { + CharSet = "utf-8", + }; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + _write(stream, _cancellation.Token); + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0) + { + try + { + _cancellation.Cancel(); + } + catch (AggregateException) + { + // A third-party provider must not make HttpContent.Dispose fail by + // throwing from a cancellation callback. Cancel invokes every + // callback before aggregating, so it is safe to observe and ignore. + } + finally + { + _cancellation.Dispose(); + } + } + + base.Dispose(disposing); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } +} diff --git a/src/OpenGameAgent.Providers.Remote/OpenGameAgent.Providers.Remote.csproj b/src/OpenGameAgent.Providers.Remote/OpenGameAgent.Providers.Remote.csproj new file mode 100644 index 0000000..57f961c --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/OpenGameAgent.Providers.Remote.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + OpenGameAgent.Providers.Remote + Compact remote IModelProvider proxy transport for OpenGameAgent. + + + + + + + + diff --git a/src/OpenGameAgent.Providers.Remote/ProxyWire.cs b/src/OpenGameAgent.Providers.Remote/ProxyWire.cs new file mode 100644 index 0000000..cf59f75 --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/ProxyWire.cs @@ -0,0 +1,790 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Remote; + +internal static class ProxyWire +{ + public const int Version = 1; + public const string SetupFrame = "s"; + public const string EventFrame = "e"; + public const string TerminalFrame = "z"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + public static string SerializeRequest(ModelRequest request) => + JsonSerializer.Serialize( + new WireRequestEnvelope { Version = Version, Request = WireModelRequest.From(request) }, + JsonOptions); + + public static ModelRequest ParseRequest(string json, int maximumDepth) + { + var envelope = Parse(json, maximumDepth, "proxy request"); + if (envelope.Version != Version || envelope.Request is null) + { + throw new InvalidDataException("The remote provider request uses an unsupported protocol version."); + } + + return Convert(() => envelope.Request.ToModelRequest(), "The remote provider request is invalid."); + } + + public static string SerializeFrame(WireFrame frame) => JsonSerializer.Serialize(frame, JsonOptions); + + public static WireFrame ParseFrame(string json, int maximumDepth) => + Parse(json, maximumDepth, "proxy stream frame"); + + public static WireResponse Response(ModelResponse response) => WireResponse.From(response); + + public static WireContent Content(AgentContent content) => WireContent.From(content); + + public static ModelResponse ToResponse(WireResponse response) => + Convert(response.ToModelResponse, "The remote provider response is invalid."); + + public static AgentContent ToContent(WireContent content) => + Convert(content.ToAgentContent, "The remote provider content is invalid."); + + public static bool ContentEquals(AgentContent left, AgentContent right) => + string.Equals( + JsonSerializer.Serialize(WireContent.From(left), JsonOptions), + JsonSerializer.Serialize(WireContent.From(right), JsonOptions), + StringComparison.Ordinal); + + public static bool ContentSequenceEquals( + IReadOnlyList left, + IReadOnlyList right) => + left.Count == right.Count && left.Zip(right, ContentEquals).All(value => value); + + public static bool ResponseEquals(ModelResponse left, ModelResponse right) => + string.Equals( + JsonSerializer.Serialize(WireResponse.From(left), JsonOptions), + JsonSerializer.Serialize(WireResponse.From(right), JsonOptions), + StringComparison.Ordinal); + + private static T Parse(string json, int maximumDepth, string description) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = maximumDepth }); + EnsureUnambiguous(document.RootElement); + return JsonSerializer.Deserialize(document.RootElement.GetRawText(), JsonOptions) + ?? throw new InvalidDataException("The " + description + " is empty."); + } + catch (JsonException exception) + { + throw new InvalidDataException("The " + description + " is not valid JSON.", exception); + } + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException("Remote provider JSON cannot contain duplicate properties."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private static T Convert(Func convert, string message) + { + try + { + return convert(); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or OverflowException) + { + throw new InvalidDataException(message + " " + exception.Message, exception); + } + } +} + +internal sealed class WireRequestEnvelope +{ + [JsonPropertyName("v")] + public int Version { get; set; } + + [JsonPropertyName("r")] + public WireModelRequest? Request { get; set; } +} + +internal sealed class WireFrame +{ + [JsonPropertyName("t")] + public string? Type { get; set; } + + [JsonPropertyName("v")] + public int? Version { get; set; } + + [JsonPropertyName("k")] + public int? Kind { get; set; } + + [JsonPropertyName("i")] + public int? ContentIndex { get; set; } + + [JsonPropertyName("d")] + public string? Delta { get; set; } + + [JsonPropertyName("id")] + public string? ToolCallId { get; set; } + + [JsonPropertyName("n")] + public string? ToolName { get; set; } + + [JsonPropertyName("x")] + public WireContent? Content { get; set; } + + [JsonPropertyName("r")] + public WireResponse? Response { get; set; } +} + +internal sealed class WireModelRequest +{ + [JsonPropertyName("m")] + public string? Model { get; set; } + + [JsonPropertyName("s")] + public string? SystemPrompt { get; set; } + + [JsonPropertyName("g")] + public List? Messages { get; set; } + + [JsonPropertyName("o")] + public List? Tools { get; set; } + + [JsonPropertyName("p")] + public WireParameters? Parameters { get; set; } + + [JsonPropertyName("q")] + public string? SessionId { get; set; } + + [JsonPropertyName("r")] + public string? RunId { get; set; } + + [JsonPropertyName("n")] + public int Turn { get; set; } + + public static WireModelRequest From(ModelRequest request) => new() + { + Model = request.Model, + SystemPrompt = request.SystemPrompt, + Messages = request.Messages.Select(WireMessage.From).ToList(), + Tools = request.Tools.Select(WireTool.From).ToList(), + Parameters = WireParameters.From(request.Parameters), + SessionId = request.SessionId, + RunId = request.RunId, + Turn = request.Turn, + }; + + public ModelRequest ToModelRequest() => new( + Model!, + SystemPrompt ?? string.Empty, + (Messages ?? new List()).Select(value => value.ToAgentMessage()).ToArray(), + (Tools ?? new List()).Select(value => value.ToToolDefinition()).ToArray(), + (Parameters ?? new WireParameters()).ToModelParameters(), + SessionId, + RunId!, + Turn); +} + +internal sealed class WireMessage +{ + [JsonPropertyName("r")] + public int Role { get; set; } + + [JsonPropertyName("c")] + public List? Content { get; set; } + + [JsonPropertyName("t")] + public DateTimeOffset Timestamp { get; set; } + + [JsonPropertyName("z")] + public string? CustomRole { get; set; } + + [JsonPropertyName("i")] + public string? ToolCallId { get; set; } + + [JsonPropertyName("n")] + public string? ToolName { get; set; } + + [JsonPropertyName("e")] + public bool IsError { get; set; } + + [JsonPropertyName("d")] + public string? DetailsJson { get; set; } + + [JsonPropertyName("x")] + public Dictionary? Metadata { get; set; } + + [JsonPropertyName("m")] + public string? Model { get; set; } + + [JsonPropertyName("s")] + public int? StopReason { get; set; } + + [JsonPropertyName("u")] + public WireUsage? Usage { get; set; } + + [JsonPropertyName("f")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("p")] + public string? Provider { get; set; } + + [JsonPropertyName("a")] + public string? Api { get; set; } + + [JsonPropertyName("rm")] + public string? ResponseModel { get; set; } + + [JsonPropertyName("ri")] + public string? ResponseId { get; set; } + + [JsonPropertyName("rs")] + public string? RawStopReason { get; set; } + + [JsonPropertyName("y")] + public bool? EndTurn { get; set; } + + [JsonPropertyName("l")] + public List? Diagnostics { get; set; } + + [JsonPropertyName("h")] + public WireDeferred? Deferred { get; set; } + + [JsonPropertyName("at")] + public List? AddedToolNames { get; set; } + + public static WireMessage From(AgentMessage message) => new() + { + Role = (int)message.Role, + Content = message.Content.Select(WireContent.From).ToList(), + Timestamp = message.Timestamp, + CustomRole = message.CustomRole, + ToolCallId = message.ToolCallId, + ToolName = message.ToolName, + IsError = message.IsError, + DetailsJson = message.DetailsJson, + Metadata = new Dictionary(message.Metadata, StringComparer.Ordinal), + Model = message.Model, + StopReason = message.StopReason is null ? null : (int)message.StopReason.Value, + Usage = message.Usage is null ? null : WireUsage.From(message.Usage), + ErrorMessage = message.ErrorMessage, + Provider = message.Provider, + Api = message.Api, + ResponseModel = message.ResponseModel, + ResponseId = message.ResponseId, + RawStopReason = message.RawStopReason, + EndTurn = message.EndTurn, + Diagnostics = message.Role == AgentRole.Assistant + ? message.Diagnostics.Select(WireDiagnostic.From).ToList() + : null, + Deferred = message.Deferred is null ? null : WireDeferred.From(message.Deferred), + AddedToolNames = message.Role == AgentRole.Tool ? message.AddedToolNames.ToList() : null, + }; + + public AgentMessage ToAgentMessage() => new( + RequireEnum(Role, nameof(Role)), + (Content ?? new List()).Select(value => value.ToAgentContent()), + Timestamp, + CustomRole, + ToolCallId, + ToolName, + IsError, + DetailsJson, + Metadata, + Model, + StopReason is null ? null : RequireEnum(StopReason.Value, nameof(StopReason)), + Usage?.ToModelUsage(), + ErrorMessage, + Provider, + Api, + ResponseModel, + ResponseId, + RawStopReason, + EndTurn, + Diagnostics?.Select(value => value.ToModelDiagnostic()), + Deferred?.ToDeferredModelHandle(), + AddedToolNames); + + internal static T RequireEnum(int value, string name) where T : struct + { + if (!Enum.IsDefined(typeof(T), value)) + { + throw new ArgumentOutOfRangeException(name); + } + + return (T)Enum.ToObject(typeof(T), value); + } +} + +internal sealed class WireContent +{ + [JsonPropertyName("k")] + public int Kind { get; set; } + + [JsonPropertyName("v")] + public string? Value { get; set; } + + [JsonPropertyName("m")] + public string? MediaType { get; set; } + + [JsonPropertyName("n")] + public string? Name { get; set; } + + [JsonPropertyName("g")] + public string? Signature { get; set; } + + [JsonPropertyName("p")] + public int? Phase { get; set; } + + [JsonPropertyName("r")] + public bool Redacted { get; set; } + + [JsonPropertyName("q")] + public int? MediaKind { get; set; } + + [JsonPropertyName("i")] + public string? Id { get; set; } + + [JsonPropertyName("a")] + public string? ArgumentsJson { get; set; } + + [JsonPropertyName("x")] + public string? Namespace { get; set; } + + public static WireContent From(AgentContent content) => content switch + { + TextContent text => new WireContent + { + Kind = (int)AgentContentKind.Text, + Value = text.Text, + Signature = text.Signature, + Phase = text.Phase is null ? null : (int)text.Phase.Value, + }, + JsonContent json => new WireContent { Kind = (int)AgentContentKind.Json, Value = json.Json }, + ResourceContent resource => new WireContent + { + Kind = (int)AgentContentKind.Resource, + Value = resource.Uri, + MediaType = resource.MediaType, + Name = resource.Name, + }, + BinaryContent binary => new WireContent + { + Kind = (int)AgentContentKind.Binary, + Value = binary.Data, + MediaType = binary.MediaType, + Name = binary.Name, + MediaKind = (int)binary.MediaKind, + }, + ReasoningContent reasoning => new WireContent + { + Kind = (int)AgentContentKind.Reasoning, + Value = reasoning.Text, + Signature = reasoning.Signature, + Redacted = reasoning.Redacted, + }, + ToolCallContent call => new WireContent + { + Kind = (int)AgentContentKind.ToolCall, + Id = call.Id, + Name = call.Name, + ArgumentsJson = call.ArgumentsJson, + Signature = call.ThoughtSignature, + Namespace = call.Namespace, + }, + _ => throw new ArgumentException("Unsupported remote provider content type.", nameof(content)), + }; + + public AgentContent ToAgentContent() + { + var kind = WireMessage.RequireEnum(Kind, nameof(Kind)); + return kind switch + { + AgentContentKind.Text => new TextContent( + Value ?? string.Empty, + Signature, + Phase is null ? null : WireMessage.RequireEnum(Phase.Value, nameof(Phase))), + AgentContentKind.Json => new JsonContent(Value!), + AgentContentKind.Resource => new ResourceContent(Value!, MediaType!, Name), + AgentContentKind.Binary => new BinaryContent( + WireMessage.RequireEnum(MediaKind ?? -1, nameof(MediaKind)), + Value!, + MediaType!, + Name), + AgentContentKind.Reasoning => new ReasoningContent(Value ?? string.Empty, Signature, Redacted), + AgentContentKind.ToolCall => new ToolCallContent(Id!, Name!, ArgumentsJson!, Signature, Namespace), + _ => throw new InvalidOperationException("Unsupported remote provider content kind."), + }; + } +} + +internal sealed class WireTool +{ + [JsonPropertyName("n")] + public string? Name { get; set; } + + [JsonPropertyName("d")] + public string? Description { get; set; } + + [JsonPropertyName("s")] + public string? InputSchemaJson { get; set; } + + [JsonPropertyName("c")] + public WireConstrainedSampling? ConstrainedSampling { get; set; } + + public static WireTool From(ToolDefinition tool) => new() + { + Name = tool.Name, + Description = tool.Description, + InputSchemaJson = tool.InputSchemaJson, + ConstrainedSampling = tool.ConstrainedSampling is null + ? null + : WireConstrainedSampling.From(tool.ConstrainedSampling), + }; + + public ToolDefinition ToToolDefinition() => + new(Name!, Description!, InputSchemaJson!, ConstrainedSampling?.ToToolConstrainedSampling()); +} + +internal sealed class WireConstrainedSampling +{ + [JsonPropertyName("k")] + public int Kind { get; set; } + + [JsonPropertyName("s")] + public int? Strictness { get; set; } + + [JsonPropertyName("l")] + public string? OpenAiLark { get; set; } + + [JsonPropertyName("r")] + public string? OpenAiRegex { get; set; } + + public static WireConstrainedSampling From(ToolConstrainedSampling value) => new() + { + Kind = (int)value.Kind, + Strictness = value.Strictness is null ? null : (int)value.Strictness.Value, + OpenAiLark = value.OpenAiLark, + OpenAiRegex = value.OpenAiRegex, + }; + + public ToolConstrainedSampling ToToolConstrainedSampling() => + WireMessage.RequireEnum(Kind, nameof(Kind)) switch + { + ToolConstrainedSamplingKind.JsonSchema => ToolConstrainedSampling.JsonSchema( + WireMessage.RequireEnum(Strictness ?? -1, nameof(Strictness))), + ToolConstrainedSamplingKind.Grammar => ToolConstrainedSampling.Grammar(OpenAiLark, OpenAiRegex), + _ => throw new InvalidOperationException("Unsupported constrained-sampling kind."), + }; +} + +internal sealed class WireParameters +{ + [JsonPropertyName("t")] + public double? Temperature { get; set; } + + [JsonPropertyName("m")] + public int? MaxOutputTokens { get; set; } + + [JsonPropertyName("r")] + public string? ReasoningLevel { get; set; } + + [JsonPropertyName("b")] + public Dictionary? ReasoningBudgets { get; set; } + + [JsonPropertyName("s")] + public string? SamplingParametersJson { get; set; } + + [JsonPropertyName("x")] + public int Transport { get; set; } + + [JsonPropertyName("c")] + public int CacheRetention { get; set; } + + [JsonPropertyName("w")] + public int? WebSocketConnectTimeoutMilliseconds { get; set; } + + [JsonPropertyName("d")] + public bool Deferred { get; set; } + + [JsonPropertyName("f")] + public int? DeferredWindow { get; set; } + + [JsonPropertyName("j")] + public string? MetadataJson { get; set; } + + [JsonPropertyName("e")] + public Dictionary? Extensions { get; set; } + + public static WireParameters From(ModelParameters parameters) => new() + { + Temperature = parameters.Temperature, + MaxOutputTokens = parameters.MaxOutputTokens, + ReasoningLevel = parameters.ReasoningLevel, + ReasoningBudgets = new Dictionary(parameters.ReasoningBudgets, StringComparer.Ordinal), + SamplingParametersJson = parameters.SamplingParametersJson, + Transport = (int)parameters.Transport, + CacheRetention = (int)parameters.CacheRetention, + WebSocketConnectTimeoutMilliseconds = parameters.WebSocketConnectTimeoutMilliseconds, + Deferred = parameters.Deferred, + DeferredWindow = parameters.DeferredWindow is null ? null : (int)parameters.DeferredWindow.Value, + MetadataJson = parameters.MetadataJson, + Extensions = new Dictionary(parameters.Extensions, StringComparer.Ordinal), + }; + + public ModelParameters ToModelParameters() => new() + { + Temperature = Temperature, + MaxOutputTokens = MaxOutputTokens, + ReasoningLevel = ReasoningLevel, + ReasoningBudgets = new ReadOnlyDictionary( + new Dictionary(ReasoningBudgets ?? new Dictionary(), StringComparer.Ordinal)), + SamplingParametersJson = SamplingParametersJson, + Transport = WireMessage.RequireEnum(Transport, nameof(Transport)), + CacheRetention = WireMessage.RequireEnum(CacheRetention, nameof(CacheRetention)), + WebSocketConnectTimeoutMilliseconds = WebSocketConnectTimeoutMilliseconds, + Deferred = Deferred, + DeferredWindow = DeferredWindow is null + ? null + : WireMessage.RequireEnum(DeferredWindow.Value, nameof(DeferredWindow)), + MetadataJson = MetadataJson, + Extensions = new ReadOnlyDictionary( + new Dictionary(Extensions ?? new Dictionary(), StringComparer.Ordinal)), + }; +} + +internal sealed class WireCost +{ + [JsonPropertyName("i")] + public double Input { get; set; } + + [JsonPropertyName("o")] + public double Output { get; set; } + + [JsonPropertyName("r")] + public double CacheRead { get; set; } + + [JsonPropertyName("w")] + public double CacheWrite { get; set; } + + public static WireCost From(ModelCost cost) => new() + { + Input = cost.Input, + Output = cost.Output, + CacheRead = cost.CacheRead, + CacheWrite = cost.CacheWrite, + }; + + public ModelCost ToModelCost() => new(Input, Output, CacheRead, CacheWrite); +} + +internal sealed class WireUsage +{ + [JsonPropertyName("i")] + public long InputTokens { get; set; } + + [JsonPropertyName("o")] + public long OutputTokens { get; set; } + + [JsonPropertyName("r")] + public long CacheReadTokens { get; set; } + + [JsonPropertyName("w")] + public long CacheWriteTokens { get; set; } + + [JsonPropertyName("g")] + public long? ReasoningTokens { get; set; } + + [JsonPropertyName("h")] + public long? CacheWriteOneHourTokens { get; set; } + + [JsonPropertyName("c")] + public WireCost? Cost { get; set; } + + public static WireUsage From(ModelUsage usage) => new() + { + InputTokens = usage.InputTokens, + OutputTokens = usage.OutputTokens, + CacheReadTokens = usage.CacheReadTokens, + CacheWriteTokens = usage.CacheWriteTokens, + ReasoningTokens = usage.ReasoningTokens, + CacheWriteOneHourTokens = usage.CacheWriteOneHourTokens, + Cost = WireCost.From(usage.Cost), + }; + + public ModelUsage ToModelUsage() => new( + InputTokens, + OutputTokens, + CacheReadTokens, + CacheWriteTokens, + ReasoningTokens, + CacheWriteOneHourTokens, + Cost?.ToModelCost()); +} + +internal sealed class WireDiagnostic +{ + [JsonPropertyName("c")] + public string? Code { get; set; } + + [JsonPropertyName("m")] + public string? Message { get; set; } + + [JsonPropertyName("s")] + public int Severity { get; set; } + + [JsonPropertyName("d")] + public string? DataJson { get; set; } + + public static WireDiagnostic From(ModelDiagnostic diagnostic) => new() + { + Code = diagnostic.Code, + Message = diagnostic.Message, + Severity = (int)diagnostic.Severity, + DataJson = diagnostic.DataJson, + }; + + public ModelDiagnostic ToModelDiagnostic() => new( + Code!, + Message!, + WireMessage.RequireEnum(Severity, nameof(Severity)), + DataJson); +} + +internal sealed class WireDeferred +{ + [JsonPropertyName("p")] + public string? Provider { get; set; } + + [JsonPropertyName("m")] + public string? Model { get; set; } + + [JsonPropertyName("a")] + public string? Api { get; set; } + + [JsonPropertyName("i")] + public string? Id { get; set; } + + [JsonPropertyName("e")] + public DateTimeOffset? ExpiresAt { get; set; } + + [JsonPropertyName("w")] + public int? PollAfterMilliseconds { get; set; } + + [JsonPropertyName("d")] + public string? DataJson { get; set; } + + public static WireDeferred From(DeferredModelHandle deferred) => new() + { + Provider = deferred.Provider, + Model = deferred.Model, + Api = deferred.Api, + Id = deferred.Id, + ExpiresAt = deferred.ExpiresAt, + PollAfterMilliseconds = deferred.PollAfterMilliseconds, + DataJson = deferred.DataJson, + }; + + public DeferredModelHandle ToDeferredModelHandle() => + new(Provider!, Model!, Api!, Id!, ExpiresAt, PollAfterMilliseconds, DataJson); +} + +internal sealed class WireResponse +{ + [JsonPropertyName("c")] + public List? Content { get; set; } + + [JsonPropertyName("s")] + public int StopReason { get; set; } + + [JsonPropertyName("u")] + public WireUsage? Usage { get; set; } + + [JsonPropertyName("e")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("p")] + public string? Provider { get; set; } + + [JsonPropertyName("a")] + public string? Api { get; set; } + + [JsonPropertyName("m")] + public string? ResponseModel { get; set; } + + [JsonPropertyName("i")] + public string? ResponseId { get; set; } + + [JsonPropertyName("r")] + public string? RawStopReason { get; set; } + + [JsonPropertyName("y")] + public bool? EndTurn { get; set; } + + [JsonPropertyName("g")] + public List? Diagnostics { get; set; } + + [JsonPropertyName("d")] + public WireDeferred? Deferred { get; set; } + + public static WireResponse From(ModelResponse response) => new() + { + Content = response.Content.Select(WireContent.From).ToList(), + StopReason = (int)response.StopReason, + Usage = WireUsage.From(response.Usage), + ErrorMessage = response.ErrorMessage, + Provider = response.Provider, + Api = response.Api, + ResponseModel = response.ResponseModel, + ResponseId = response.ResponseId, + RawStopReason = response.RawStopReason, + EndTurn = response.EndTurn, + Diagnostics = response.Diagnostics.Select(WireDiagnostic.From).ToList(), + Deferred = response.Deferred is null ? null : WireDeferred.From(response.Deferred), + }; + + public ModelResponse ToModelResponse() => new( + (Content ?? new List()).Select(value => value.ToAgentContent()), + WireMessage.RequireEnum(StopReason, nameof(StopReason)), + Usage?.ToModelUsage() ?? throw new InvalidDataException("A remote provider response requires usage."), + ErrorMessage, + Provider, + Api, + ResponseModel, + ResponseId, + RawStopReason, + EndTurn, + Diagnostics?.Select(value => value.ToModelDiagnostic()), + Deferred?.ToDeferredModelHandle()); +} diff --git a/src/OpenGameAgent.Providers.Remote/RemoteModelProvider.cs b/src/OpenGameAgent.Providers.Remote/RemoteModelProvider.cs new file mode 100644 index 0000000..5093e94 --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/RemoteModelProvider.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Providers.Remote; + +public sealed class RemoteModelProvider : IModelProvider +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, true); + private readonly RemoteModelProviderSettings _settings; + + public RemoteModelProvider(RemoteModelProviderOptions options) + { + _settings = (options ?? throw new ArgumentNullException(nameof(options))).Validate(); + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var requestJson = ProxyWire.SerializeRequest(request); + if (StrictUtf8.GetByteCount(requestJson) > _settings.MaximumRequestBytes) + { + throw new InvalidDataException("The remote provider request exceeded the configured size limit."); + } + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _settings.Endpoint) + { + Content = new StringContent(requestJson, StrictUtf8, "application/json"), + }; + httpRequest.Headers.TryAddWithoutValidation("Accept", "text/event-stream"); + foreach (var pair in _settings.Headers) + { + if (!httpRequest.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) + { + throw new InvalidOperationException("A configured remote provider header could not be applied."); + } + } + + if (!string.IsNullOrEmpty(_settings.ApiKey)) + { + var credential = string.IsNullOrEmpty(_settings.ApiKeyScheme) + ? _settings.ApiKey + : _settings.ApiKeyScheme + " " + _settings.ApiKey; + if (!httpRequest.Headers.TryAddWithoutValidation(_settings.ApiKeyHeader, credential)) + { + throw new InvalidOperationException("The configured remote provider API key header could not be applied."); + } + } + + using var response = await _settings.HttpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var errorBody = await ReadBoundedBodyAsync( + response.Content, + _settings.MaximumEventBytes, + cancellationToken).ConfigureAwait(false); + throw new HttpRequestException( + "Remote provider HTTP error " + + ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture) + + ": " + + errorBody); + } + + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (!string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("The remote provider response must use text/event-stream."); + } + + var responseStreamTask = response.Content.ReadAsStreamAsync(); + using var pendingContentRegistration = cancellationToken.Register(response.Content.Dispose); + using var responseStream = await AwaitWithCancellation(responseStreamTask, cancellationToken).ConfigureAwait(false); + using var registration = cancellationToken.Register(responseStream.Dispose); + using var boundedStream = new BoundedReadStream(responseStream, _settings.MaximumResponseBytes); + using var reader = new StreamReader(boundedStream, StrictUtf8, false, 4096, leaveOpen: false); + var decoder = new RemoteStreamDecoder(); + var data = new StringBuilder(); + var dataBytes = 0; + var hasDataLine = false; + var frameCount = 0; + ModelStreamEvent? terminal = null; + + while (true) + { + string? line; + try + { + line = await reader.ReadLineAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + catch (IOException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (line is null) + { + break; + } + + if (line.Length == 0) + { + if (!hasDataLine) + { + continue; + } + + var decoded = DecodeFrame(data.ToString(), decoder, ref frameCount); + data.Clear(); + dataBytes = 0; + hasDataLine = false; + if (decoded is null) + { + continue; + } + + if (decoded.IsTerminal) + { + if (terminal is not null) + { + throw new InvalidDataException("The remote provider stream emitted more than one terminal event."); + } + + terminal = decoded; + } + else + { + if (terminal is not null) + { + throw new InvalidDataException("The remote provider stream emitted an event after its terminal event."); + } + + yield return decoded; + } + + continue; + } + + if (line.StartsWith(":", StringComparison.Ordinal)) + { + continue; + } + + if (!line.StartsWith("data:", StringComparison.Ordinal)) + { + throw new InvalidDataException("The remote provider stream contains an unsupported SSE field."); + } + + var value = line.Substring(5); + if (value.Length > 0 && value[0] == ' ') + { + value = value.Substring(1); + } + + var valueBytes = StrictUtf8.GetByteCount(value); + var separatorBytes = hasDataLine ? 1 : 0; + if ((long)dataBytes + separatorBytes + valueBytes > _settings.MaximumEventBytes) + { + throw new InvalidDataException("A remote provider stream event exceeded the configured size limit."); + } + + if (hasDataLine) + { + data.Append('\n'); + } + + data.Append(value); + dataBytes += separatorBytes + valueBytes; + hasDataLine = true; + } + + if (hasDataLine) + { + var decoded = DecodeFrame(data.ToString(), decoder, ref frameCount); + if (decoded is { IsTerminal: true }) + { + if (terminal is not null) + { + throw new InvalidDataException("The remote provider stream emitted more than one terminal event."); + } + + terminal = decoded; + } + else if (decoded is not null) + { + if (terminal is not null) + { + throw new InvalidDataException("The remote provider stream emitted an event after its terminal event."); + } + + yield return decoded; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + decoder.EnsureComplete(); + yield return terminal + ?? throw new InvalidDataException("The remote provider stream ended without a terminal event."); + } + + private ModelStreamEvent? DecodeFrame(string json, RemoteStreamDecoder decoder, ref int frameCount) + { + if (StrictUtf8.GetByteCount(json) > _settings.MaximumEventBytes) + { + throw new InvalidDataException("A remote provider stream event exceeded the configured size limit."); + } + + frameCount++; + if (frameCount > _settings.MaximumEvents) + { + throw new InvalidDataException("The remote provider stream exceeded the configured event limit."); + } + + return decoder.Decode(ProxyWire.ParseFrame(json, _settings.MaximumJsonDepth)); + } + + private static async Task ReadBoundedBodyAsync( + HttpContent content, + int maximumBytes, + CancellationToken cancellationToken) + { + using var source = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var registration = cancellationToken.Register(source.Dispose); + using var bounded = new BoundedReadStream(source, maximumBytes); + using var reader = new StreamReader(bounded, StrictUtf8, false, 4096, leaveOpen: false); + try + { + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + } + + private static async Task AwaitWithCancellation(Task task, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled || task.IsCompleted) + { + return await task.ConfigureAwait(false); + } + + var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(() => canceled.TrySetResult(true)); + if (await Task.WhenAny(task, canceled.Task).ConfigureAwait(false) != task) + { + ObserveLateFault(task); + throw new OperationCanceledException(cancellationToken); + } + + return await task.ConfigureAwait(false); + } + + private static void ObserveLateFault(Task task) + { + _ = task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } +} + +internal sealed class RemoteStreamDecoder +{ + private readonly List _content = new(); + private readonly Dictionary _active = new(); + private ModelResponse? _setup; + private bool _started; + private bool _terminal; + private bool _sawContentEvents; + + public ModelStreamEvent? Decode(WireFrame frame) + { + if (frame is null || string.IsNullOrWhiteSpace(frame.Type)) + { + throw new InvalidDataException("A remote provider stream frame requires a type."); + } + + if (_terminal) + { + throw new InvalidDataException("The remote provider stream emitted data after its terminal event."); + } + + return frame.Type switch + { + ProxyWire.SetupFrame => Setup(frame), + ProxyWire.EventFrame => Update(frame), + ProxyWire.TerminalFrame => Terminal(frame), + _ => throw new InvalidDataException("The remote provider stream contains an unknown frame type."), + }; + } + + public void EnsureComplete() + { + if (_setup is null) + { + throw new InvalidDataException("The remote provider stream did not contain setup metadata."); + } + + if (!_terminal) + { + throw new InvalidDataException("The remote provider stream ended without a terminal event."); + } + } + + private ModelStreamEvent? Setup(WireFrame frame) + { + if (_setup is not null + || frame.Version != ProxyWire.Version + || frame.Response is null + || frame.Kind is not null + || frame.ContentIndex is not null + || frame.Delta is not null + || frame.ToolCallId is not null + || frame.ToolName is not null + || frame.Content is not null) + { + throw new InvalidDataException("The remote provider setup frame is invalid or duplicated."); + } + + _setup = ProxyWire.ToResponse(frame.Response); + if (_setup.StopReason != ModelStopReason.Pending || _setup.Deferred is not null) + { + throw new InvalidDataException("The remote provider setup response must be pending."); + } + + _content.AddRange(_setup.Content); + return null; + } + + private ModelStreamEvent Update(WireFrame frame) + { + if (_setup is null || frame.Version is not null || frame.Response is not null || frame.Kind is null) + { + throw new InvalidDataException("A remote provider update frame appeared before setup or has invalid fields."); + } + + var kind = WireMessage.RequireEnum(frame.Kind.Value, nameof(frame.Kind)); + if (kind is ModelStreamEventKind.Completed or ModelStreamEventKind.Failed) + { + throw new InvalidDataException("Terminal model events require a terminal frame."); + } + + if (kind == ModelStreamEventKind.Started) + { + if (_started + || frame.ContentIndex is not null + || frame.Delta is not null + || frame.ToolCallId is not null + || frame.ToolName is not null + || frame.Content is not null) + { + throw new InvalidDataException("The remote provider start event is invalid or duplicated."); + } + + _started = true; + return ModelStreamEvent.Update(ModelStreamEventKind.Started, Snapshot()); + } + + if (!_started) + { + throw new InvalidDataException("A remote provider content event appeared before the start event."); + } + + var index = frame.ContentIndex + ?? throw new InvalidDataException("A remote provider content event requires an index."); + if (index < 0) + { + throw new InvalidDataException("A remote provider content index cannot be negative."); + } + + var family = Family(kind); + if (IsStart(kind)) + { + if (index != _content.Count || _active.ContainsKey(index) || frame.Content is null || frame.Delta is not null) + { + throw new InvalidDataException("A remote provider content block started out of order."); + } + + var content = ProxyWire.ToContent(frame.Content); + RequireFamily(content, family); + _content.Add(content); + _active.Add(index, family); + _sawContentEvents = true; + return CreateUpdate(kind, index, null, content, frame.ToolCallId, frame.ToolName); + } + + if (!_active.TryGetValue(index, out var activeFamily) || activeFamily != family || index >= _content.Count) + { + throw new InvalidDataException("A remote provider content update referenced a missing or ended block."); + } + + if (IsDelta(kind)) + { + if (frame.Delta is null) + { + throw new InvalidDataException("A remote provider delta event requires delta content."); + } + + if (family == ContentFamily.Tool) + { + var replacement = frame.Content is null + ? throw new InvalidDataException("A remote tool delta requires a normalized partial tool call.") + : ProxyWire.ToContent(frame.Content); + RequireFamily(replacement, family); + var previous = (ToolCallContent)_content[index]; + var current = (ToolCallContent)replacement; + if (!string.Equals(previous.Id, current.Id, StringComparison.Ordinal)) + { + throw new InvalidDataException("A remote tool call changed identity while streaming."); + } + + _content[index] = replacement; + } + else + { + if (frame.Content is not null) + { + throw new InvalidDataException("Text and reasoning deltas cannot carry replacement content."); + } + + _content[index] = Append(_content[index], frame.Delta); + } + + return CreateUpdate( + kind, + index, + frame.Delta, + _content[index], + frame.ToolCallId, + frame.ToolName); + } + + if (!IsEnd(kind) || frame.Content is null || frame.Delta is not null) + { + throw new InvalidDataException("The remote provider content event kind is invalid."); + } + + var finalContent = ProxyWire.ToContent(frame.Content); + RequireFamily(finalContent, family); + if (family == ContentFamily.Tool + && !string.Equals(((ToolCallContent)_content[index]).Id, ((ToolCallContent)finalContent).Id, StringComparison.Ordinal)) + { + throw new InvalidDataException("A remote tool call changed identity before completion."); + } + + _content[index] = finalContent; + _active.Remove(index); + return CreateUpdate(kind, index, null, finalContent, frame.ToolCallId, frame.ToolName); + } + + private ModelStreamEvent Terminal(WireFrame frame) + { + if (_setup is null + || frame.Version is not null + || frame.Kind is not null + || frame.ContentIndex is not null + || frame.Delta is not null + || frame.ToolCallId is not null + || frame.ToolName is not null + || frame.Content is not null + || frame.Response is null) + { + throw new InvalidDataException("The remote provider terminal frame is invalid."); + } + + var response = ProxyWire.ToResponse(frame.Response); + if (response.StopReason == ModelStopReason.Pending) + { + throw new InvalidDataException("The remote provider terminal response cannot be pending."); + } + + if (_active.Count > 0 && response.StopReason is not ModelStopReason.Error and not ModelStopReason.Aborted) + { + throw new InvalidDataException("A successful remote provider terminal response arrived with open content blocks."); + } + + if (!_started && response.StopReason is not ModelStopReason.Error and not ModelStopReason.Aborted) + { + throw new InvalidDataException("A successful remote provider terminal response requires a start event."); + } + + if (_sawContentEvents && !ProxyWire.ContentSequenceEquals(_content, response.Content)) + { + throw new InvalidDataException("The remote provider terminal response disagrees with the streamed content."); + } + + _terminal = true; + return ModelStreamEvent.Terminal(response); + } + + private ModelResponse Snapshot() => new( + _content, + ModelStopReason.Pending, + _setup!.Usage, + provider: _setup.Provider, + api: _setup.Api, + responseModel: _setup.ResponseModel, + responseId: _setup.ResponseId, + rawStopReason: _setup.RawStopReason, + endTurn: _setup.EndTurn, + diagnostics: _setup.Diagnostics); + + private ModelStreamEvent CreateUpdate( + ModelStreamEventKind kind, + int index, + string? delta, + AgentContent content, + string? toolCallId, + string? toolName) + { + var tool = content as ToolCallContent; + return ModelStreamEvent.Update( + kind, + Snapshot(), + delta, + index, + toolCallId, + toolName, + kind == ModelStreamEventKind.ToolCallEnded ? tool : null, + kind is ModelStreamEventKind.TextEnded or ModelStreamEventKind.ReasoningEnded + ? ContentText(content) + : null); + } + + private static AgentContent Append(AgentContent content, string delta) => content switch + { + TextContent text => new TextContent(text.Text + delta, text.Signature, text.Phase), + ReasoningContent reasoning => new ReasoningContent( + reasoning.Text + delta, + reasoning.Signature, + reasoning.Redacted), + _ => throw new InvalidDataException("A remote text delta targeted non-text content."), + }; + + private static string ContentText(AgentContent content) => content switch + { + TextContent text => text.Text, + ReasoningContent reasoning => reasoning.Text, + _ => throw new InvalidDataException("A remote content end event targeted non-text content."), + }; + + private static void RequireFamily(AgentContent content, ContentFamily family) + { + if ((family == ContentFamily.Text && content is TextContent) + || (family == ContentFamily.Reasoning && content is ReasoningContent) + || (family == ContentFamily.Tool && content is ToolCallContent)) + { + return; + } + + throw new InvalidDataException("The remote provider content type does not match its event kind."); + } + + private static ContentFamily Family(ModelStreamEventKind kind) => kind switch + { + ModelStreamEventKind.TextStarted or ModelStreamEventKind.TextDelta or ModelStreamEventKind.TextEnded => + ContentFamily.Text, + ModelStreamEventKind.ReasoningStarted or ModelStreamEventKind.ReasoningDelta or ModelStreamEventKind.ReasoningEnded => + ContentFamily.Reasoning, + ModelStreamEventKind.ToolCallStarted or ModelStreamEventKind.ToolCallDelta or ModelStreamEventKind.ToolCallEnded => + ContentFamily.Tool, + _ => throw new InvalidDataException("The remote provider event is not a content event."), + }; + + private static bool IsStart(ModelStreamEventKind kind) => kind is + ModelStreamEventKind.TextStarted or + ModelStreamEventKind.ReasoningStarted or + ModelStreamEventKind.ToolCallStarted; + + private static bool IsDelta(ModelStreamEventKind kind) => kind is + ModelStreamEventKind.TextDelta or + ModelStreamEventKind.ReasoningDelta or + ModelStreamEventKind.ToolCallDelta; + + private static bool IsEnd(ModelStreamEventKind kind) => kind is + ModelStreamEventKind.TextEnded or + ModelStreamEventKind.ReasoningEnded or + ModelStreamEventKind.ToolCallEnded; + + private enum ContentFamily + { + Text, + Reasoning, + Tool, + } +} + +internal sealed class BoundedReadStream : Stream +{ + private readonly Stream _inner; + private readonly long _maximumBytes; + private long _read; + + public BoundedReadStream(Stream inner, long maximumBytes) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _maximumBytes = maximumBytes > 0 ? maximumBytes : throw new ArgumentOutOfRangeException(nameof(maximumBytes)); + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => _read; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + var value = _inner.Read(buffer, offset, count); + Count(value); + return value; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + var value = await _inner.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + Count(value); + return value; + } + + private void Count(int value) + { + _read = checked(_read + value); + if (_read > _maximumBytes) + { + throw new InvalidDataException("The remote provider response exceeded the configured size limit."); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/OpenGameAgent.Providers.Remote/RemoteModelProviderOptions.cs b/src/OpenGameAgent.Providers.Remote/RemoteModelProviderOptions.cs new file mode 100644 index 0000000..c2883d8 --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/RemoteModelProviderOptions.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; + +namespace OpenGameAgent.Providers.Remote; + +public sealed class RemoteModelProviderOptions +{ + public const int DefaultMaximumRequestBytes = 8_000_000; + public const int DefaultMaximumResponseBytes = 32_000_000; + public const int DefaultMaximumEventBytes = 8_000_000; + public const int DefaultMaximumEvents = 100_000; + public const int DefaultMaximumJsonDepth = 128; + + public RemoteModelProviderOptions(HttpClient httpClient, Uri endpoint) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + } + + public HttpClient HttpClient { get; } + + public Uri Endpoint { get; } + + public bool AllowInsecureHttp { get; set; } + + public string? ApiKey { get; set; } + + public string ApiKeyHeader { get; set; } = "Authorization"; + + public string ApiKeyScheme { get; set; } = "Bearer"; + + public IReadOnlyDictionary Headers { get; set; } = + new ReadOnlyDictionary(new Dictionary()); + + public int MaximumRequestBytes { get; set; } = DefaultMaximumRequestBytes; + + public int MaximumResponseBytes { get; set; } = DefaultMaximumResponseBytes; + + public int MaximumEventBytes { get; set; } = DefaultMaximumEventBytes; + + public int MaximumEvents { get; set; } = DefaultMaximumEvents; + + public int MaximumJsonDepth { get; set; } = DefaultMaximumJsonDepth; + + internal RemoteModelProviderSettings Validate() + { + if (!Endpoint.IsAbsoluteUri + || Endpoint.UserInfo.Length > 0 + || Endpoint.Fragment.Length > 0 + || (!string.Equals(Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("The remote provider endpoint must be an absolute HTTP or HTTPS URI.", nameof(Endpoint)); + } + + if (string.Equals(Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !Endpoint.IsLoopback + && !AllowInsecureHttp) + { + throw new ArgumentException( + "Remote provider endpoints must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(Endpoint)); + } + + ValidateLimit(MaximumRequestBytes, nameof(MaximumRequestBytes)); + ValidateLimit(MaximumResponseBytes, nameof(MaximumResponseBytes)); + ValidateLimit(MaximumEventBytes, nameof(MaximumEventBytes)); + if (MaximumEvents < 2 || MaximumEvents > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumEvents)); + } + + if (MaximumJsonDepth < 1 || MaximumJsonDepth > 1_024) + { + throw new ArgumentOutOfRangeException(nameof(MaximumJsonDepth)); + } + + ValidateCredential(ApiKey, nameof(ApiKey), 65_536); + ValidateCredential(ApiKeyHeader, nameof(ApiKeyHeader), 256); + ValidateCredential(ApiKeyScheme, nameof(ApiKeyScheme), 256, allowEmpty: true); + if (!IsValidHeaderName(ApiKeyHeader)) + { + throw new ArgumentException("A valid API key header name is required.", nameof(ApiKeyHeader)); + } + + var headers = Headers is null + ? throw new ArgumentNullException(nameof(Headers)) + : new Dictionary(Headers, StringComparer.OrdinalIgnoreCase); + if (headers.Count != Headers.Count + || headers.Any(pair => !IsValidHeaderName(pair.Key) + || !IsValidHeaderValue(pair.Value) + || string.Equals(pair.Key, "Content-Type", StringComparison.OrdinalIgnoreCase) + || string.Equals(pair.Key, "Accept", StringComparison.OrdinalIgnoreCase) + || string.Equals(pair.Key, ApiKeyHeader, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("Remote provider headers contain an invalid, duplicate, or reserved entry.", nameof(Headers)); + } + + return new RemoteModelProviderSettings( + HttpClient, + Endpoint, + ApiKey, + ApiKeyHeader, + ApiKeyScheme, + new ReadOnlyDictionary(headers), + MaximumRequestBytes, + MaximumResponseBytes, + MaximumEventBytes, + MaximumEvents, + MaximumJsonDepth); + } + + private static void ValidateLimit(int value, string name) + { + if (value < 2 || value > 100_000_000) + { + throw new ArgumentOutOfRangeException(name); + } + } + + internal static bool IsValidHeaderName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + try + { + using var request = new HttpRequestMessage(); + return request.Headers.TryAddWithoutValidation(name, "value"); + } + catch (FormatException) + { + return false; + } + } + + internal static bool IsValidHeaderValue(string? value) => + value is not null + && value.Length <= 65_536 + && value.IndexOfAny(new[] { '\r', '\n', '\0' }) < 0; + + internal static void ValidateCredential(string? value, string name, int maximumLength, bool allowEmpty = false) + { + if (value is null) + { + return; + } + + if ((!allowEmpty && string.IsNullOrWhiteSpace(value)) + || value.Length > maximumLength + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A remote provider credential or header value is invalid.", name); + } + } +} + +internal sealed class RemoteModelProviderSettings +{ + public RemoteModelProviderSettings( + HttpClient httpClient, + Uri endpoint, + string? apiKey, + string apiKeyHeader, + string apiKeyScheme, + IReadOnlyDictionary headers, + int maximumRequestBytes, + int maximumResponseBytes, + int maximumEventBytes, + int maximumEvents, + int maximumJsonDepth) + { + HttpClient = httpClient; + Endpoint = endpoint; + ApiKey = apiKey; + ApiKeyHeader = apiKeyHeader; + ApiKeyScheme = apiKeyScheme; + Headers = headers; + MaximumRequestBytes = maximumRequestBytes; + MaximumResponseBytes = maximumResponseBytes; + MaximumEventBytes = maximumEventBytes; + MaximumEvents = maximumEvents; + MaximumJsonDepth = maximumJsonDepth; + } + + public HttpClient HttpClient { get; } + public Uri Endpoint { get; } + public string? ApiKey { get; } + public string ApiKeyHeader { get; } + public string ApiKeyScheme { get; } + public IReadOnlyDictionary Headers { get; } + public int MaximumRequestBytes { get; } + public int MaximumResponseBytes { get; } + public int MaximumEventBytes { get; } + public int MaximumEvents { get; } + public int MaximumJsonDepth { get; } +} + +public sealed class ModelProviderProxyServerOptions +{ + public string? ApiKey { get; set; } + + public string ApiKeyHeader { get; set; } = "Authorization"; + + public string ApiKeyScheme { get; set; } = "Bearer"; + + public int MaximumRequestBytes { get; set; } = RemoteModelProviderOptions.DefaultMaximumRequestBytes; + + public int MaximumResponseBytes { get; set; } = RemoteModelProviderOptions.DefaultMaximumResponseBytes; + + public int MaximumEventBytes { get; set; } = RemoteModelProviderOptions.DefaultMaximumEventBytes; + + public int MaximumEvents { get; set; } = RemoteModelProviderOptions.DefaultMaximumEvents; + + public int MaximumJsonDepth { get; set; } = RemoteModelProviderOptions.DefaultMaximumJsonDepth; +} diff --git a/src/OpenGameAgent.Providers.Remote/packages.lock.json b/src/OpenGameAgent.Providers.Remote/packages.lock.json new file mode 100644 index 0000000..ef5d71f --- /dev/null +++ b/src/OpenGameAgent.Providers.Remote/packages.lock.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.6, )", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "8.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Server/packages.lock.json b/src/OpenGameAgent.Server/packages.lock.json index a01264c..14b2af1 100644 --- a/src/OpenGameAgent.Server/packages.lock.json +++ b/src/OpenGameAgent.Server/packages.lock.json @@ -17,7 +17,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -26,6 +27,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, "opengameagent.persistence": { "type": "Project", "dependencies": { @@ -38,8 +45,12 @@ "type": "Project", "dependencies": { "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.providertransport": { + "type": "Project" } } } diff --git a/src/OpenGameAgent/AssemblyInfo.cs b/src/OpenGameAgent/AssemblyInfo.cs new file mode 100644 index 0000000..3ea7b3f --- /dev/null +++ b/src/OpenGameAgent/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenGameAgent.Persistence")] diff --git a/src/OpenGameAgent/ExtensionHost.cs b/src/OpenGameAgent/ExtensionHost.cs index bfc7d1a..73dc2f3 100644 --- a/src/OpenGameAgent/ExtensionHost.cs +++ b/src/OpenGameAgent/ExtensionHost.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using OpenGameAgent.Kernel; @@ -1072,9 +1073,9 @@ public static AgentHooks Compose(IReadOnlyList hooks) } : null, BeforeToolCallAsync = hooks.Any(hook => hook.BeforeToolCallAsync is not null) - ? async (call, context, cancellationToken) => + ? async (hookContext, cancellationToken) => { - var current = call; + var current = hookContext.ToolCall; var replaced = false; foreach (var hook in hooks) { @@ -1083,7 +1084,16 @@ public static AgentHooks Compose(IReadOnlyList hooks) continue; } - var decision = await hook.BeforeToolCallAsync(current, context, cancellationToken).ConfigureAwait(false); + using var argumentsDocument = JsonDocument.Parse(current.ArgumentsJson); + var decision = await hook.BeforeToolCallAsync( + new BeforeToolCallContext( + hookContext.RunId, + hookContext.Turn, + hookContext.AssistantMessage, + current, + argumentsDocument.RootElement, + hookContext.Context), + cancellationToken).ConfigureAwait(false); if (decision?.Blocked == true) { return decision; @@ -1091,7 +1101,7 @@ public static AgentHooks Compose(IReadOnlyList hooks) if (decision?.ReplacementArgumentsJson is not null) { - var tool = context.Tools.FirstOrDefault(candidate => + var tool = hookContext.Context.Tools.FirstOrDefault(candidate => string.Equals(candidate.Definition.Name, current.Name, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"Tool '{current.Name}' is not available during hook composition."); var validationError = tool.ValidateArguments(decision.ReplacementArgumentsJson); @@ -1109,14 +1119,23 @@ public static AgentHooks Compose(IReadOnlyList hooks) } : null, AfterToolCallAsync = hooks.Any(hook => hook.AfterToolCallAsync is not null) - ? async (call, result, context, cancellationToken) => + ? async (hookContext, cancellationToken) => { - var current = result; + var current = hookContext.Result; foreach (var hook in hooks) { if (hook.AfterToolCallAsync is not null) { - current = await hook.AfterToolCallAsync(call, current, context, cancellationToken).ConfigureAwait(false) + current = await hook.AfterToolCallAsync( + new AfterToolCallContext( + hookContext.RunId, + hookContext.Turn, + hookContext.AssistantMessage, + hookContext.ToolCall, + hookContext.Arguments, + current, + hookContext.Context), + cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("An extension tool result transform returned null."); } } diff --git a/src/OpenGameAgent/GameAgentRuntime.cs b/src/OpenGameAgent/GameAgentRuntime.cs index b08acdc..21319a5 100644 --- a/src/OpenGameAgent/GameAgentRuntime.cs +++ b/src/OpenGameAgent/GameAgentRuntime.cs @@ -743,6 +743,9 @@ await _extensions.PublishAsync( var maximumOutputTokens = selection?.MaximumOutputTokens ?? 0; var systemPrompt = ComposeSystemPrompt(context, skills); var agentLimits = CopyAgentLimits(_agentLimits); + var usageAccounting = new RunUsageAccounting(input.InputId, agentLimits.MaxTotalTokens); + var legacyUsageRecords = CreateLegacyUsageRecords(loaded); + var baseUsageLedger = loaded.UsageLedger.Append(legacyUsageRecords); IReadOnlyList initialMessages = loaded.Messages; var minimumMessageReserve = resumingCheckpoint ? 1 : 2; var preferredMessageReserve = activeTools.Count == 0 @@ -751,18 +754,43 @@ await _extensions.PublishAsync( var additionalMessages = resumingCheckpoint ? Array.Empty() : new[] { CreateInputMessage(input) }; - initialMessages = await FitTranscriptAsync( - loaded.Key, - initialMessages, - Math.Max(1, agentLimits.MaxMessages - preferredMessageReserve), - additionalMessages, - model, - systemPrompt, - activeTools.Select(tool => tool.Definition).ToArray(), - parameters, - contextWindowTokens, - maximumOutputTokens, - cancellationToken).ConfigureAwait(false); + try + { + initialMessages = await FitTranscriptAsync( + loaded.Key, + initialMessages, + Math.Max(1, agentLimits.MaxMessages - preferredMessageReserve), + additionalMessages, + model, + systemPrompt, + activeTools.Select(tool => tool.Definition).ToArray(), + parameters, + contextWindowTokens, + maximumOutputTokens, + usageAccounting, + cancellationToken).ConfigureAwait(false); + } + catch (GameTranscriptCompactionException exception) + { + var usageRecords = usageAccounting.RecordsBetween(0, usageAccounting.Count); + GameSessionSnapshot settled; + using (var settlementCancellation = new CancellationTokenSource(_sessionCommitTimeoutMilliseconds)) + { + settled = await SaveUsageOnlyAsync( + loaded, + legacyUsageRecords.Concat(usageRecords).ToArray(), + baseUsageLedger.Append(usageRecords), + settlementCancellation.Token).ConfigureAwait(false); + } + + var failed = new GameAgentRunResult( + GameAgentRunStatus.Failed, + route, + settled.Revision, + error: exception.Message); + await PublishCompletedAsync(failed, extensionContext, CancellationToken.None).ConfigureAwait(false); + return failed; + } if (resumingCheckpoint) { @@ -781,7 +809,32 @@ await _extensions.PublishAsync( } var commitBase = loaded; + var committedUsageRecordCount = 0; GameSessionSaveResult? checkpointConflict = null; + IReadOnlyList? checkpointConflictUsageRecords = null; + GameSessionUsageLedger? checkpointConflictUsageLedger = null; + var recoverySafety = new GameModelRecoverySafety(resumingCheckpoint); + Func? wrapRecoveryProvider = null; + if (_transcriptCompactor is not null && contextWindowTokens > 0) + { + wrapRecoveryProvider = candidate => new ContextOverflowRecoveryModelProvider( + candidate, + recoverySafety, + contextWindowTokens, + (request, token) => CompactOverflowRequestAsync( + loaded.Key, + request, + contextWindowTokens, + maximumOutputTokens, + usageAccounting, + token), + usageAccounting.RecordRecoveryAttemptAndSuppress, + usageAccounting.Record, + usageAccounting.Record, + usageAccounting.ClearAssistantSuppression); + provider = wrapRecoveryProvider(provider); + } + var runHooks = CreateRunHooks( route.Route, input, @@ -789,7 +842,25 @@ await _extensions.PublishAsync( model, parameters, contextWindowTokens, - maximumOutputTokens); + maximumOutputTokens, + usageAccounting); + if (wrapRecoveryProvider is not null) + { + var configured = runHooks.PrepareNextTurnAsync; + runHooks.PrepareNextTurnAsync = async (turnContext, token) => + { + var update = configured is null + ? null + : await configured(turnContext, token).ConfigureAwait(false); + if (update?.Provider is not null) + { + update.Provider = wrapRecoveryProvider(update.Provider); + } + + return update; + }; + } + if (_persistToolTurnCheckpoints && route.Route == GameRouteKind.Agent) { var configured = runHooks.PrepareNextTurnAsync; @@ -802,6 +873,10 @@ await _extensions.PublishAsync( : await configured(turnContext, token).ConfigureAwait(false); } + var usageEndIndex = usageAccounting.Count; + var usageRecords = usageAccounting.RecordsBetween( + committedUsageRecordCount, + usageEndIndex); var checkpoint = new GameSessionSnapshot( commitBase.Key, checked(commitBase.Revision + 1), @@ -809,7 +884,10 @@ await _extensions.PublishAsync( commitBase.ProcessedInputIds, commitBase.LastMoment, extensionState.SnapshotAll(), - input.InputId); + input.InputId, + (commitBase.Revision == loaded.Revision + ? baseUsageLedger + : commitBase.UsageLedger).Append(usageRecords)); var checkpointSave = await _sessionStore.SaveAsync( checkpoint, commitBase.Revision, @@ -819,11 +897,16 @@ await _extensions.PublishAsync( if (!checkpointSave.Saved) { checkpointConflict = checkpointSave; + checkpointConflictUsageRecords = commitBase.Revision == loaded.Revision + ? legacyUsageRecords.Concat(usageRecords).ToArray() + : usageRecords; + checkpointConflictUsageLedger = checkpoint.UsageLedger; throw new InvalidOperationException( "The session changed while a tool turn was being checkpointed."); } commitBase = checkpointSave.Current; + committedUsageRecordCount = usageEndIndex; return configured is null ? null : await configured(turnContext, token).ConfigureAwait(false); @@ -852,6 +935,8 @@ await _extensions.PublishAsync( var agent = new Agent(options); using var subscription = agent.Subscribe(async (agentEvent, token) => { + recoverySafety.Record(agentEvent); + usageAccounting.Record(agentEvent); if (observer is not null) { await observer(input, agentEvent, token).ConfigureAwait(false); @@ -878,10 +963,22 @@ await _extensions.PublishAsync( if (checkpointConflict is not null) { + GameSessionSnapshot settledConflict; + using (var settlementCancellation = new CancellationTokenSource(_sessionCommitTimeoutMilliseconds)) + { + settledConflict = await SettleUsageAfterConflictAsync( + checkpointConflict.Current, + checkpointConflictUsageRecords + ?? throw new InvalidOperationException("Checkpoint usage settlement state is missing."), + checkpointConflictUsageLedger + ?? throw new InvalidOperationException("Checkpoint usage ledger state is missing."), + settlementCancellation.Token).ConfigureAwait(false); + } + var conflict = new GameAgentRunResult( GameAgentRunStatus.SessionConflict, route, - checkpointConflict.Current.Revision, + settledConflict.Revision, run, "The session changed while this input was running. Committed game actions must be reconciled before retrying."); await PublishCompletedAsync(conflict, extensionContext, CancellationToken.None).ConfigureAwait(false); @@ -889,34 +986,55 @@ await _extensions.PublishAsync( } GameSessionSaveResult save; + GameSessionSnapshot? settledSaveConflict = null; using (var settlementCancellation = new CancellationTokenSource(_sessionCommitTimeoutMilliseconds)) { + var finalUsageRecords = usageAccounting.RecordsBetween( + committedUsageRecordCount, + usageAccounting.Count); + var usageLedger = (commitBase.Revision == loaded.Revision + ? baseUsageLedger + : commitBase.UsageLedger).Append(finalUsageRecords); save = await SaveAsync( input, commitBase, agent.State.Messages, extensionState, extensionContext, + usageLedger, settlementCancellation.Token).ConfigureAwait(false); + if (!save.Saved) + { + settledSaveConflict = await SettleUsageAfterConflictAsync( + save.Current, + commitBase.Revision == loaded.Revision + ? legacyUsageRecords.Concat(finalUsageRecords).ToArray() + : finalUsageRecords, + usageLedger, + settlementCancellation.Token).ConfigureAwait(false); + } } if (!save.Saved) { var conflict = new GameAgentRunResult( GameAgentRunStatus.SessionConflict, route, - save.Current.Revision, + settledSaveConflict!.Revision, run, "The session changed while this input was running. Committed game actions must be reconciled before retrying."); await PublishCompletedAsync(conflict, extensionContext, CancellationToken.None).ConfigureAwait(false); return conflict; } + var usageExceeded = usageAccounting.Exceeded; var completed = new GameAgentRunResult( - run.Succeeded ? GameAgentRunStatus.Completed : GameAgentRunStatus.Failed, + run.Succeeded && !usageExceeded ? GameAgentRunStatus.Completed : GameAgentRunStatus.Failed, route, save.Current.Revision, run, - run.Error); + usageExceeded + ? $"The run exceeded the maximum of {agentLimits.MaxTotalTokens} total tokens, including transcript compaction." + : run.Error); await PublishCompletedAsync(completed, extensionContext, CancellationToken.None).ConfigureAwait(false); return completed; } @@ -1016,6 +1134,7 @@ void ValidateWorkflowOutput(IReadOnlyList output) messages, extensionState, extensionContext, + loaded.UsageLedger.Append(CreateLegacyUsageRecords(loaded)), settlementCancellation.Token).ConfigureAwait(false); } return !save.Saved @@ -1033,6 +1152,7 @@ private async ValueTask SaveAsync( IReadOnlyList messages, GameAgentSessionState extensionState, GameAgentExtensionRunContext extensionContext, + GameSessionUsageLedger usageLedger, CancellationToken cancellationToken) { await _extensions.PublishAsync( @@ -1052,7 +1172,8 @@ await _extensions.PublishAsync( processed, input.Moment, extensionState.SnapshotAll(), - pendingInputId: null); + pendingInputId: null, + usageLedger); var save = await _sessionStore.SaveAsync(snapshot, loaded.Revision, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The game session store returned null."); ValidateSaveResult(loaded, snapshot, save); @@ -1069,6 +1190,90 @@ await _extensions.PublishAsync( return save; } + private async ValueTask SaveUsageOnlyAsync( + GameSessionSnapshot current, + IReadOnlyList usageRecords, + GameSessionUsageLedger attemptedLedger, + CancellationToken cancellationToken) + { + if (UsageLedgerEquals(current.UsageLedger, attemptedLedger)) + { + return current; + } + + var candidate = new GameSessionSnapshot( + current.Key, + checked(current.Revision + 1), + current.Messages, + current.ProcessedInputIds, + current.LastMoment, + current.ExtensionState, + current.PendingInputId, + attemptedLedger); + var save = await _sessionStore.SaveAsync( + candidate, + current.Revision, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game session store returned null."); + ValidateSaveResult(current, candidate, save); + return save.Saved + ? save.Current + : await SettleUsageAfterConflictAsync( + save.Current, + usageRecords, + attemptedLedger, + cancellationToken).ConfigureAwait(false); + } + + private async ValueTask SettleUsageAfterConflictAsync( + GameSessionSnapshot current, + IReadOnlyList usageRecords, + GameSessionUsageLedger attemptedLedger, + CancellationToken cancellationToken) + { + const int maximumAttempts = 8; + for (var attempt = 0; attempt < maximumAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (UsageLedgerEquals(current.UsageLedger, attemptedLedger)) + { + return current; + } + + var merged = current.UsageLedger.Append(usageRecords); + if (ReferenceEquals(merged, current.UsageLedger)) + { + return current; + } + + var candidate = new GameSessionSnapshot( + current.Key, + checked(current.Revision + 1), + current.Messages, + current.ProcessedInputIds, + current.LastMoment, + current.ExtensionState, + current.PendingInputId, + merged); + var save = await _sessionStore.SaveAsync( + candidate, + current.Revision, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game session store returned null."); + ValidateSaveResult(current, candidate, save); + if (save.Saved) + { + return save.Current; + } + + attemptedLedger = merged; + current = save.Current; + } + + throw new InvalidOperationException( + $"The session usage ledger could not be settled after {maximumAttempts} compare-and-swap attempts."); + } + private static void ValidateSaveResult( GameSessionSnapshot expectedBase, GameSessionSnapshot candidate, @@ -1099,7 +1304,8 @@ private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionS || !left.ProcessedInputIds.SequenceEqual(right.ProcessedInputIds, StringComparer.Ordinal) || !left.ExtensionState.OrderBy(pair => pair.Key, StringComparer.Ordinal) .SequenceEqual(right.ExtensionState.OrderBy(pair => pair.Key, StringComparer.Ordinal)) - || left.Messages.Count != right.Messages.Count) + || left.Messages.Count != right.Messages.Count + || !UsageLedgerEquals(left.UsageLedger, right.UsageLedger)) { return false; } @@ -1115,6 +1321,42 @@ private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionS return true; } + private static bool UsageLedgerEquals(GameSessionUsageLedger left, GameSessionUsageLedger right) => + left.Records.Count == right.Records.Count + && left.TotalRecordCount == right.TotalRecordCount + && left.RecentRecordCapacity == right.RecentRecordCapacity + && left.TotalsByCause.Count == right.TotalsByCause.Count + && left.TotalsByCause.All(pair => + right.TotalsByCause.TryGetValue(pair.Key, out var total) + && GameSessionUsageTotals.ValueEquals(pair.Value, total)) + && left.Records.Zip(right.Records, GameSessionUsageRecord.ValueEquals).All(equal => equal); + + private static IReadOnlyList CreateLegacyUsageRecords(GameSessionSnapshot session) + { + if (session.UsageLedger.TotalRecordCount != 0) + { + return Array.Empty(); + } + + var records = session.Messages + .Select((message, index) => new { Message = message, Index = index }) + .Where(item => item.Message.Usage is not null + && (item.Message.Usage.TotalTokens > 0 || item.Message.Usage.Cost.Total > 0) + && item.Message.Role is AgentRole.Assistant or AgentRole.Tool) + .Select(item => new GameSessionUsageRecord( + $"legacy-message-{item.Index}", + item.Message.Role == AgentRole.Assistant + ? GameSessionUsageCause.Assistant + : GameSessionUsageCause.Tool, + item.Message.Usage!, + inputId: item.Message.Metadata.TryGetValue("game.input_id", out var inputId) + && !string.IsNullOrWhiteSpace(inputId) + ? inputId + : null)) + .ToArray(); + return Array.AsReadOnly(records); + } + private string ComposeSystemPrompt( IReadOnlyList context, IReadOnlyList skills) @@ -1227,7 +1469,8 @@ private AgentHooks CreateRunHooks( string model, ModelParameters parameters, int contextWindowTokens, - int maximumOutputTokens) + int maximumOutputTokens, + RunUsageAccounting usageAccounting) { var hooks = CopyHooks(_agentHooks); if (route == GameRouteKind.QuickResponse) @@ -1277,6 +1520,7 @@ private AgentHooks CreateRunHooks( nextParameters, contextWindowTokens, maximumOutputTokens, + usageAccounting, cancellationToken).ConfigureAwait(false); return new NextTurnUpdate { @@ -1295,6 +1539,7 @@ private AgentHooks CreateRunHooks( nextParameters, contextWindowTokens, maximumOutputTokens, + usageAccounting, cancellationToken).ConfigureAwait(false); return new NextTurnUpdate { @@ -1333,6 +1578,42 @@ private AgentHooks CreateRunHooks( }; } + var configuredBeforeModelRequest = hooks.BeforeModelRequestAsync; + hooks.BeforeModelRequestAsync = async (request, cancellationToken) => + { + if (usageAccounting.Exceeded) + { + throw usageAccounting.CreateLimitException(); + } + + return configuredBeforeModelRequest is null + ? request + : await configuredBeforeModelRequest(request, cancellationToken).ConfigureAwait(false); + }; + + var configuredBeforeToolCall = hooks.BeforeToolCallAsync; + hooks.BeforeToolCallAsync = async (context, cancellationToken) => + { + if (usageAccounting.Exceeded) + { + return ToolCallDecision.Block( + usageAccounting.CreateLimitException().Message, + terminate: true); + } + + return configuredBeforeToolCall is null + ? null + : await configuredBeforeToolCall(context, cancellationToken).ConfigureAwait(false); + }; + + var configuredShouldStop = hooks.ShouldStopAfterTurnAsync; + hooks.ShouldStopAfterTurnAsync = async (context, cancellationToken) => + { + var configuredStop = configuredShouldStop is not null + && await configuredShouldStop(context, cancellationToken).ConfigureAwait(false); + return configuredStop || usageAccounting.Exceeded; + }; + return hooks; } @@ -1344,6 +1625,7 @@ private async ValueTask RefreshTurnContextAsync( ModelParameters parameters, int contextWindowTokens, int maximumOutputTokens, + RunUsageAccounting usageAccounting, CancellationToken cancellationToken) { var baseContext = _contextProvider is null @@ -1398,6 +1680,7 @@ private async ValueTask RefreshTurnContextAsync( parameters, contextWindowTokens, maximumOutputTokens, + usageAccounting, cancellationToken).ConfigureAwait(false); return new AgentContext(systemPrompt, compacted, tools); } @@ -1413,6 +1696,7 @@ private async ValueTask> FitTranscriptAsync( ModelParameters parameters, int contextWindowTokens, int maximumOutputTokens, + RunUsageAccounting usageAccounting, CancellationToken cancellationToken) { var tokenTarget = GetTranscriptTokenTarget( @@ -1429,15 +1713,26 @@ private async ValueTask> FitTranscriptAsync( IReadOnlyList fitted = messages; if ((messageCompactionRequired || tokenCompactionRequired) && _transcriptCompactor is not null) { - fitted = await _transcriptCompactor.CompactAsync( - new GameTranscriptCompactionContext( - session, - messages, - targetMessageCount, - tokenTarget, - tokenTarget is null ? null : _transcriptTokenEstimator), - cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The transcript compactor returned null."); + try + { + var compaction = await _transcriptCompactor.CompactAsync( + new GameTranscriptCompactionContext( + session, + messages, + targetMessageCount, + tokenTarget, + tokenTarget is null ? null : _transcriptTokenEstimator, + usageAccounting.RemainingTokens), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The transcript compactor returned null."); + usageAccounting.Record(compaction); + fitted = compaction.Messages; + } + catch (GameTranscriptCompactionException exception) + { + usageAccounting.Record(exception); + throw; + } } if (fitted.Count > targetMessageCount) @@ -1468,6 +1763,112 @@ _transcriptCompactor is null return fitted; } + private async ValueTask CompactOverflowRequestAsync( + GameSessionKey session, + ModelRequest request, + int contextWindowTokens, + int maximumOutputTokens, + RunUsageAccounting usageAccounting, + CancellationToken cancellationToken) + { + if (_transcriptCompactor is null || usageAccounting.Exceeded) + { + return null; + } + + var protectedStart = -1; + for (var index = request.Messages.Count - 1; index >= 0; index--) + { + if (request.Messages[index].Role == AgentRole.User) + { + protectedStart = index; + break; + } + } + + if (protectedStart < 2) + { + return null; + } + + var history = request.Messages.Take(protectedStart).ToArray(); + var protectedTail = request.Messages.Skip(protectedStart).ToArray(); + GameTranscriptStructure.ValidateToolExchanges(history); + var available = GetAvailableInputTokens( + request.Parameters, + contextWindowTokens, + maximumOutputTokens); + var safetyMargin = Math.Max(256L, contextWindowTokens / 20L); + var recoveryAvailable = available - safetyMargin; + if (recoveryAvailable <= 0) + { + return null; + } + + var fixedTokens = EstimateRequestTokens( + request.Model, + request.SystemPrompt, + protectedTail, + request.Tools); + var historyTarget = recoveryAvailable - fixedTokens; + if (historyTarget <= 0) + { + return null; + } + + var summaryUsageBudget = usageAccounting.RemainingTokens; + if (summaryUsageBudget <= 0) + { + return null; + } + + var compaction = await _transcriptCompactor.CompactAsync( + new GameTranscriptCompactionContext( + session, + history, + Math.Max(1, history.Length - 1), + historyTarget, + _transcriptTokenEstimator, + summaryUsageBudget), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The transcript compactor returned null."); + if (compaction.Usage.TotalTokens > usageAccounting.RemainingTokens) + { + throw new GameTranscriptCompactionException( + "recovery_usage_limit_exceeded", + "The failed provider attempt and recovery compaction exhausted the run token budget.", + compaction.Usage, + compaction.Details); + } + + var recoveredMessages = compaction.Messages.Concat(protectedTail).ToArray(); + AgentValidation.ValidateTranscript(recoveredMessages, _agentLimits); + if (EstimateRequestTokens( + request.Model, + request.SystemPrompt, + recoveredMessages, + request.Tools) > recoveryAvailable) + { + throw new GameTranscriptCompactionException( + "recovery_target_exceeded", + "The recovered transcript still exceeds the conservative context-window target.", + compaction.Usage, + compaction.Details); + } + + return new GameModelRecoveryCompaction( + new ModelRequest( + request.Model, + request.SystemPrompt, + recoveredMessages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn), + compaction); + } + private long? GetTranscriptTokenTarget( string model, string systemPrompt, @@ -1535,6 +1936,192 @@ private static long ValidateTokenEstimate(long estimate, string kind) => ? estimate : throw new InvalidOperationException($"The {kind} token estimator returned an invalid value."); + private sealed class RunUsageAccounting + { + private readonly object _gate = new(); + private readonly string _attemptId = Guid.NewGuid().ToString("N"); + private readonly string _inputId; + private readonly long _maximumTokens; + private readonly List _records = new(); + private readonly HashSet _suppressedAssistantRuns = new(StringComparer.Ordinal); + private long _totalTokens; + private int _sequence; + + public RunUsageAccounting(string inputId, long maximumTokens) + { + _inputId = GameJson.RequireId(inputId, nameof(inputId)); + _maximumTokens = maximumTokens; + } + + public bool Exceeded + { + get + { + lock (_gate) + { + return _totalTokens > _maximumTokens; + } + } + } + + public int Count + { + get + { + lock (_gate) + { + return _records.Count; + } + } + } + + public long RemainingTokens + { + get + { + lock (_gate) + { + return Math.Max(0, _maximumTokens - _totalTokens); + } + } + } + + public void Record(GameTranscriptCompactionResult result) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + Add( + GameSessionUsageCause.Compaction, + result.Usage, + _attemptId, + JsonSerializer.Serialize(result.Details)); + } + + public void Record(GameTranscriptCompactionException exception) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + if (exception.Usage.TotalTokens == 0 && exception.Usage.Cost.Total == 0) + { + return; + } + + Add( + GameSessionUsageCause.Compaction, + exception.Usage, + _attemptId, + JsonSerializer.Serialize(exception.Details)); + } + + public void Record(AgentEvent agentEvent) + { + if (agentEvent is null) + { + throw new ArgumentNullException(nameof(agentEvent)); + } + + if (agentEvent.Kind == AgentEventKind.MessageEnded + && agentEvent.Message?.Role == AgentRole.Assistant + && agentEvent.Message.Usage is not null) + { + lock (_gate) + { + if (_suppressedAssistantRuns.Remove(agentEvent.RunId)) + { + return; + } + } + + Add(GameSessionUsageCause.Assistant, agentEvent.Message.Usage, agentEvent.RunId, detailsJson: null); + } + else if (agentEvent.Kind == AgentEventKind.ToolEnded + && agentEvent.ToolResult?.Usage is not null) + { + Add(GameSessionUsageCause.Tool, agentEvent.ToolResult.Usage, agentEvent.RunId, detailsJson: null); + } + } + + public void ClearAssistantSuppression(string runId) + { + runId = GameJson.RequireId(runId, nameof(runId)); + lock (_gate) + { + _suppressedAssistantRuns.Remove(runId); + } + } + + public void RecordRecoveryAttemptAndSuppress(ModelUsage usage, string runId, string kind) + { + if (usage is null) + { + throw new ArgumentNullException(nameof(usage)); + } + + runId = GameJson.RequireId(runId, nameof(runId)); + lock (_gate) + { + if (!_suppressedAssistantRuns.Add(runId)) + { + throw new InvalidOperationException("An assistant usage suppression is already active for this run."); + } + + Add( + GameSessionUsageCause.Assistant, + usage, + runId, + JsonSerializer.Serialize(new + { + category = "context_overflow_recovery", + attempt = 1, + outcome = kind, + })); + } + } + + public IReadOnlyList RecordsBetween(int startIndex, int endIndex) + { + lock (_gate) + { + if (startIndex < 0 || endIndex < startIndex || endIndex > _records.Count) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + return Array.AsReadOnly(_records.Skip(startIndex).Take(endIndex - startIndex).ToArray()); + } + } + + public GameRuntimeLimitException CreateLimitException() => new( + nameof(AgentLimits.MaxTotalTokens), + $"The run exceeded the maximum of {_maximumTokens} total tokens, including transcript compaction."); + + private void Add( + GameSessionUsageCause cause, + ModelUsage usage, + string runId, + string? detailsJson) + { + lock (_gate) + { + var sequence = checked(_sequence++); + _records.Add(new GameSessionUsageRecord( + $"{_attemptId}-{sequence}", + cause, + usage, + runId, + _inputId, + detailsJson)); + _totalTokens = checked(_totalTokens + usage.TotalTokens); + } + } + } + private static AgentHooks CopyHooks(AgentHooks value) => new() { TransformContextAsync = value.TransformContextAsync, diff --git a/src/OpenGameAgent/GameResources.cs b/src/OpenGameAgent/GameResources.cs new file mode 100644 index 0000000..cb62028 --- /dev/null +++ b/src/OpenGameAgent/GameResources.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenGameAgent; + +public sealed class GameResourceSourceInfo +{ + public GameResourceSourceInfo( + string source, + string basePath, + string filePath, + string? scope = null, + IReadOnlyDictionary? metadata = null) + { + Source = GameJson.RequireId(source, nameof(source)); + BasePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); + FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + Scope = scope; + var copied = new Dictionary( + metadata ?? new Dictionary(), + StringComparer.Ordinal); + if (copied.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)) + { + throw new ArgumentException("Resource source metadata requires non-empty keys and non-null values.", nameof(metadata)); + } + + Metadata = new ReadOnlyDictionary(copied); + } + + public string Source { get; } + + public string? Scope { get; } + + public string BasePath { get; } + + public string FilePath { get; } + + public IReadOnlyDictionary Metadata { get; } +} + +public enum GameResourceDiagnosticSeverity +{ + Warning, +} + +public static class GameResourceDiagnosticCodes +{ + public const string FileInfoFailed = "file_info_failed"; + public const string ListFailed = "list_failed"; + public const string ReadFailed = "read_failed"; + public const string ParseFailed = "parse_failed"; + public const string InvalidMetadata = "invalid_metadata"; + public const string LimitExceeded = "limit_exceeded"; + public const string UnsupportedEntry = "unsupported_entry"; +} + +public sealed class GameResourceDiagnostic +{ + public GameResourceDiagnostic( + GameResourceDiagnosticSeverity severity, + string code, + string message, + string path, + GameResourceSourceInfo? sourceInfo = null) + { + if (!Enum.IsDefined(typeof(GameResourceDiagnosticSeverity), severity)) + { + throw new ArgumentOutOfRangeException(nameof(severity)); + } + + Severity = severity; + Code = GameJson.RequireId(code, nameof(code)); + Message = message ?? throw new ArgumentNullException(nameof(message)); + Path = path ?? throw new ArgumentNullException(nameof(path)); + SourceInfo = sourceInfo; + } + + public GameResourceDiagnosticSeverity Severity { get; } + + public string Code { get; } + + public string Message { get; } + + public string Path { get; } + + public GameResourceSourceInfo? SourceInfo { get; } +} + +public sealed class GameSkillDiscoveryResult +{ + public GameSkillDiscoveryResult( + IEnumerable skills, + IEnumerable? diagnostics = null) + { + Skills = Copy(skills, nameof(skills)); + Diagnostics = Copy(diagnostics ?? Array.Empty(), nameof(diagnostics)); + } + + public IReadOnlyList Skills { get; } + + public IReadOnlyList Diagnostics { get; } + + private static IReadOnlyList Copy(IEnumerable values, string parameterName) + where T : class + { + if (values is null) + { + throw new ArgumentNullException(parameterName); + } + + var copied = values.ToArray(); + if (copied.Any(value => value is null)) + { + throw new ArgumentException("Resource result collections cannot contain null values.", parameterName); + } + + return Array.AsReadOnly(copied); + } +} + +public sealed class GamePromptTemplate +{ + public GamePromptTemplate( + string name, + string content, + string? description = null, + string? argumentHint = null, + GameResourceSourceInfo? sourceInfo = null) + { + Name = GameJson.RequireId(name, nameof(name)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + Description = description ?? string.Empty; + ArgumentHint = argumentHint; + SourceInfo = sourceInfo; + } + + public string Name { get; } + + public string Description { get; } + + public string? ArgumentHint { get; } + + public string Content { get; } + + public GameResourceSourceInfo? SourceInfo { get; } +} + +public sealed class GamePromptTemplateLoadResult +{ + public GamePromptTemplateLoadResult( + IEnumerable promptTemplates, + IEnumerable? diagnostics = null) + { + if (promptTemplates is null) + { + throw new ArgumentNullException(nameof(promptTemplates)); + } + + if (diagnostics is null) + { + diagnostics = Array.Empty(); + } + + var copiedTemplates = promptTemplates.ToArray(); + var copiedDiagnostics = diagnostics.ToArray(); + if (copiedTemplates.Any(value => value is null) || copiedDiagnostics.Any(value => value is null)) + { + throw new ArgumentException("Resource result collections cannot contain null values."); + } + + PromptTemplates = Array.AsReadOnly(copiedTemplates); + Diagnostics = Array.AsReadOnly(copiedDiagnostics); + } + + public IReadOnlyList PromptTemplates { get; } + + public IReadOnlyList Diagnostics { get; } +} + +public static class GamePromptTemplateFormatter +{ + private static readonly Regex Placeholder = new( + @"\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)", + RegexOptions.CultureInvariant); + + public static IReadOnlyList ParseArguments(string value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + var arguments = new List(); + var current = new StringBuilder(); + char? quote = null; + foreach (var character in value) + { + if (quote is not null) + { + if (character == quote.Value) + { + quote = null; + } + else + { + current.Append(character); + } + } + else if (character is '\'' or '"') + { + quote = character; + } + else if (char.IsWhiteSpace(character)) + { + AddCurrent(arguments, current); + } + else + { + current.Append(character); + } + } + + AddCurrent(arguments, current); + return Array.AsReadOnly(arguments.ToArray()); + } + + public static string Substitute(string content, IReadOnlyList arguments) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + if (arguments.Any(value => value is null)) + { + throw new ArgumentException("Template arguments cannot contain null values.", nameof(arguments)); + } + + var all = string.Join(" ", arguments); + return Placeholder.Replace(content, match => + { + if (match.Groups[1].Success) + { + var start = ParseIndex(match.Groups[1].Value); + if (start < 0) + { + start = 0; + } + + int? count = match.Groups[2].Success + ? ParseIndex(match.Groups[2].Value, subtractOne: false) + : null; + if (count is not null && count.Value <= 0) + { + return string.Empty; + } + + return string.Join( + " ", + count is null + ? arguments.Skip(start) + : arguments.Skip(start).Take(count.Value)); + } + + var simple = match.Groups[3].Value; + if (simple is "@" or "ARGUMENTS") + { + return all; + } + + var index = ParseIndex(simple); + return index >= 0 && index < arguments.Count ? arguments[index] : string.Empty; + }); + } + + public static string Format(GamePromptTemplate template, IReadOnlyList? arguments = null) + { + if (template is null) + { + throw new ArgumentNullException(nameof(template)); + } + + return Substitute(template.Content, arguments ?? Array.Empty()); + } + + private static int ParseIndex(string value, bool subtractOne = true) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + { + return int.MaxValue; + } + + return subtractOne ? parsed - 1 : parsed; + } + + private static void AddCurrent(List arguments, StringBuilder current) + { + if (current.Length == 0) + { + return; + } + + arguments.Add(current.ToString()); + current.Clear(); + } +} + +public static class GameSkillFormatter +{ + public static string FormatInvocation(GameSkill skill, string? additionalInstructions = null) + { + if (skill is null) + { + throw new ArgumentNullException(nameof(skill)); + } + + var filePath = skill.SourceInfo?.FilePath ?? string.Empty; + var basePath = skill.SourceInfo is null + ? string.Empty + : Path.GetDirectoryName(skill.SourceInfo.FilePath) ?? skill.SourceInfo.BasePath; + var formatted = "\nReferences are relative to " + + EscapeXml(basePath) + + ".\n\n" + + skill.Instructions + + "\n"; + return string.IsNullOrEmpty(additionalInstructions) + ? formatted + : formatted + "\n\n" + additionalInstructions; + } + + private static string EscapeXml(string value) => value + .Replace("&", "&", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); +} diff --git a/src/OpenGameAgent/GameSessionHistory.cs b/src/OpenGameAgent/GameSessionHistory.cs new file mode 100644 index 0000000..1793314 --- /dev/null +++ b/src/OpenGameAgent/GameSessionHistory.cs @@ -0,0 +1,2222 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent; + +public sealed class GameHistoryLimits +{ + public int MaxSessions { get; set; } = 10_000; + public int MaxEntriesPerSession { get; set; } = 100_000; + public int MaxRecordsPerSession { get; set; } = 100_000; + public int MaxMutationsPerSession { get; set; } = 250_000; + public int MaxLanesPerSession { get; set; } = 256; + public int DefaultQueryResults { get; set; } = 100; + public int MaxQueryResults { get; set; } = 1_000; + public int MaxSearchResults { get; set; } = 200; + public int MaxSearchScannedEntries { get; set; } = 100_000; + public int MaxIdentifierCharacters { get; set; } = 256; + public int MaxTypeCharacters { get; set; } = 128; + public int MaxPayloadCharacters { get; set; } = 1_000_000; + public int MaxFactCharacters { get; set; } = 4_096; + public int MaxSearchCharacters { get; set; } = 4_096; + public int MaxContextMessages { get; set; } = 1_024; + public int MaxContextStateCharacters { get; set; } = 1_000_000; + + internal GameHistoryLimits CopyAndValidate() + { + var copy = (GameHistoryLimits)MemberwiseClone(); + Range(copy.MaxSessions, 1, 1_000_000, nameof(MaxSessions)); + Range(copy.MaxEntriesPerSession, 1, 10_000_000, nameof(MaxEntriesPerSession)); + Range(copy.MaxRecordsPerSession, 0, 10_000_000, nameof(MaxRecordsPerSession)); + Range(copy.MaxMutationsPerSession, 1, 20_000_000, nameof(MaxMutationsPerSession)); + Range(copy.MaxLanesPerSession, 1, 100_000, nameof(MaxLanesPerSession)); + Range(copy.DefaultQueryResults, 1, 100_000, nameof(DefaultQueryResults)); + Range(copy.MaxQueryResults, copy.DefaultQueryResults, 1_000_000, nameof(MaxQueryResults)); + Range(copy.MaxSearchResults, 1, 100_000, nameof(MaxSearchResults)); + Range(copy.MaxSearchScannedEntries, 1, 10_000_000, nameof(MaxSearchScannedEntries)); + Range(copy.MaxIdentifierCharacters, 1, 16_384, nameof(MaxIdentifierCharacters)); + Range(copy.MaxTypeCharacters, 1, 16_384, nameof(MaxTypeCharacters)); + Range(copy.MaxPayloadCharacters, 2, 100_000_000, nameof(MaxPayloadCharacters)); + Range(copy.MaxFactCharacters, 1, 10_000_000, nameof(MaxFactCharacters)); + Range(copy.MaxSearchCharacters, 1, 1_000_000, nameof(MaxSearchCharacters)); + Range(copy.MaxContextMessages, 1, 100_000, nameof(MaxContextMessages)); + Range(copy.MaxContextStateCharacters, 2, 100_000_000, nameof(MaxContextStateCharacters)); + return copy; + } + + private static void Range(int value, int min, int max, string name) + { + if (value < min || value > max) + { + throw new ArgumentOutOfRangeException(name); + } + } +} + +public enum GameHistoryErrorCode +{ + NotFound, + AlreadyExists, + InvalidInput, + InvalidQuery, + InvalidLane, + InvalidForkTarget, + Conflict, + LimitExceeded, + CorruptStorage, + Storage, +} + +public class GameHistoryException : Exception +{ + public GameHistoryException(GameHistoryErrorCode code, string message, Exception? innerException = null) + : base(message, innerException) + { + Code = code; + } + + public GameHistoryErrorCode Code { get; } +} + +public sealed class GameHistoryConcurrencyException : GameHistoryException +{ + public GameHistoryConcurrencyException(long expectedSequence, long actualSequence) + : base(GameHistoryErrorCode.Conflict, $"History sequence conflict: expected {expectedSequence}, found {actualSequence}.") + { + ExpectedSequence = expectedSequence; + ActualSequence = actualSequence; + } + + public long ExpectedSequence { get; } + public long ActualSequence { get; } +} + +public sealed class GameHistoryCommitException : GameHistoryException +{ + public GameHistoryCommitException(string mutationId, bool outcomeUnknown, string message, Exception innerException) + : base(GameHistoryErrorCode.Storage, message, innerException) + { + MutationId = mutationId; + OutcomeUnknown = outcomeUnknown; + } + + public string MutationId { get; } + public bool OutcomeUnknown { get; } +} + +public sealed class GameHistoryMetadata +{ + public GameHistoryMetadata( + string id, + DateTimeOffset createdAt, + string? parentSessionId = null, + string? metadataJson = null, + DateTimeOffset? modifiedAt = null) + { + Id = GameHistoryObjectValidation.Required(id, nameof(id)); + CreatedAt = createdAt; + ParentSessionId = GameHistoryObjectValidation.Optional(parentSessionId, nameof(parentSessionId)); + MetadataJson = metadataJson is null ? null : GameHistoryObjectValidation.JsonObject(metadataJson, nameof(metadataJson)); + ModifiedAt = modifiedAt ?? createdAt; + } + + public string Id { get; } + public DateTimeOffset CreatedAt { get; } + public string? ParentSessionId { get; } + public string? MetadataJson { get; } + public DateTimeOffset ModifiedAt { get; } +} + +public sealed class GameHistoryEntry +{ + public GameHistoryEntry( + string id, + long sequence, + string? parentId, + DateTimeOffset timestamp, + string type, + string payloadJson) + { + Id = GameHistoryObjectValidation.Required(id, nameof(id)); + Sequence = GameHistoryObjectValidation.Sequence(sequence, nameof(sequence)); + ParentId = GameHistoryObjectValidation.Optional(parentId, nameof(parentId)); + Timestamp = timestamp; + Type = GameHistoryObjectValidation.Required(type, nameof(type)); + PayloadJson = GameHistoryObjectValidation.Json(payloadJson, nameof(payloadJson)); + } + + public string Id { get; } + public long Sequence { get; } + public string? ParentId { get; } + public DateTimeOffset Timestamp { get; } + public string Type { get; } + public string PayloadJson { get; } +} + +public sealed class GameHistoryRecord +{ + public GameHistoryRecord( + string id, + long sequence, + DateTimeOffset timestamp, + string lane, + string type, + string payloadJson) + { + Id = GameHistoryObjectValidation.Required(id, nameof(id)); + Sequence = GameHistoryObjectValidation.Sequence(sequence, nameof(sequence)); + Timestamp = timestamp; + Lane = GameHistoryObjectValidation.Required(lane, nameof(lane)); + Type = GameHistoryObjectValidation.Required(type, nameof(type)); + PayloadJson = GameHistoryObjectValidation.Json(payloadJson, nameof(payloadJson)); + } + + public string Id { get; } + public long Sequence { get; } + public DateTimeOffset Timestamp { get; } + public string Lane { get; } + public string Type { get; } + public string PayloadJson { get; } +} + +public sealed class GameHistoryLane +{ + public GameHistoryLane(string name, string? leafEntryId) + { + Name = GameHistoryObjectValidation.Required(name, nameof(name)); + LeafEntryId = GameHistoryObjectValidation.Optional(leafEntryId, nameof(leafEntryId)); + } + + public string Name { get; } + public string? LeafEntryId { get; } +} + +public enum GameHistoryMutationKind +{ + Entry, + Record, + Lane, + Name, + Label, +} + +public sealed class GameHistoryLogItem +{ + public GameHistoryLogItem( + string mutationId, + long sequence, + GameHistoryMutationKind kind, + GameHistoryEntry? entry = null, + GameHistoryRecord? record = null, + string? lane = null, + string? leafEntryId = null, + bool? createsLane = null, + string? name = null, + string? targetEntryId = null, + string? label = null) + { + MutationId = GameHistoryObjectValidation.Required(mutationId, nameof(mutationId)); + Sequence = GameHistoryObjectValidation.Sequence(sequence, nameof(sequence)); + if (!Enum.IsDefined(typeof(GameHistoryMutationKind), kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + var valid = kind switch + { + GameHistoryMutationKind.Entry => entry is not null + && entry.Sequence == sequence + && record is null + && createsLane is null + && name is null + && targetEntryId is null + && label is null, + GameHistoryMutationKind.Record => entry is null + && record is not null + && record.Sequence == sequence + && string.Equals(lane, record.Lane, StringComparison.Ordinal) + && leafEntryId is null + && createsLane is null + && name is null + && targetEntryId is null + && label is null, + GameHistoryMutationKind.Lane => entry is null + && record is null + && lane is not null + && createsLane is not null + && name is null + && targetEntryId is null + && label is null, + GameHistoryMutationKind.Name => entry is null + && record is null + && lane is null + && leafEntryId is null + && createsLane is null + && name is not null + && targetEntryId is null + && label is null, + GameHistoryMutationKind.Label => entry is null + && record is null + && lane is null + && leafEntryId is null + && createsLane is null + && name is null + && targetEntryId is not null, + _ => false, + }; + if (!valid) + { + throw new ArgumentException("The history log fields do not match its mutation kind.", nameof(kind)); + } + + Kind = kind; + Entry = entry; + Record = record; + Lane = GameHistoryObjectValidation.Optional(lane, nameof(lane)); + LeafEntryId = GameHistoryObjectValidation.Optional(leafEntryId, nameof(leafEntryId)); + CreatesLane = createsLane; + Name = GameHistoryObjectValidation.Optional(name, nameof(name)); + TargetEntryId = GameHistoryObjectValidation.Optional(targetEntryId, nameof(targetEntryId)); + Label = GameHistoryObjectValidation.Optional(label, nameof(label)); + } + + public string MutationId { get; } + public long Sequence { get; } + public GameHistoryMutationKind Kind { get; } + public GameHistoryEntry? Entry { get; } + public GameHistoryRecord? Record { get; } + public string? Lane { get; } + public string? LeafEntryId { get; } + public bool? CreatesLane { get; } + public string? Name { get; } + public string? TargetEntryId { get; } + public string? Label { get; } +} + +public sealed class GameHistoryStats +{ + public GameHistoryStats(long entryCount, long recordCount, int laneCount, long mutationCount, long lastSequence) + { + if (entryCount < 0 || recordCount < 0 || laneCount < 1 || mutationCount < 0 || lastSequence < 0) + { + throw new ArgumentOutOfRangeException(nameof(entryCount)); + } + + EntryCount = entryCount; + RecordCount = recordCount; + LaneCount = laneCount; + MutationCount = mutationCount; + LastSequence = lastSequence; + } + + public long EntryCount { get; } + public long RecordCount { get; } + public int LaneCount { get; } + public long MutationCount { get; } + public long LastSequence { get; } +} + +public sealed class GameHistoryPage +{ + public GameHistoryPage(IEnumerable items, long? nextSequence) + { + if (nextSequence is < 1) + { + throw new ArgumentOutOfRangeException(nameof(nextSequence)); + } + + var copied = (items ?? throw new ArgumentNullException(nameof(items))).ToArray(); + if (copied.Any(item => item is null)) + { + throw new ArgumentException("A history page cannot contain null items.", nameof(items)); + } + + Items = Array.AsReadOnly(copied); + NextSequence = nextSequence; + } + + public IReadOnlyList Items { get; } + public long? NextSequence { get; } +} + +public enum GameHistoryOrder +{ + NewestFirst, + OldestFirst, +} + +public sealed class GameHistoryEntryQuery +{ + public string? Type { get; set; } + public GameHistoryOrder Order { get; set; } = GameHistoryOrder.NewestFirst; + public int? Limit { get; set; } + public long? CursorSequence { get; set; } +} + +public sealed class GameHistoryBranchQuery +{ + public string? StartEntryId { get; set; } + public string? StopAtEntryId { get; set; } + public string? StopAtType { get; set; } + public string? Type { get; set; } + public GameHistoryOrder Order { get; set; } = GameHistoryOrder.NewestFirst; + public int? Limit { get; set; } + public long? CursorSequence { get; set; } +} + +public sealed class GameHistoryRecordQuery +{ + public string? Lane { get; set; } + public string? Type { get; set; } + public GameHistoryOrder Order { get; set; } = GameHistoryOrder.NewestFirst; + public int? Limit { get; set; } + public long? CursorSequence { get; set; } +} + +public sealed class GameHistoryLogQuery +{ + public long AfterSequence { get; set; } + public int? Limit { get; set; } +} + +public sealed class GameHistoryCreateOptions +{ + public string? Id { get; set; } + public string? ParentSessionId { get; set; } + public string? MetadataJson { get; set; } +} + +public sealed class GameHistoryListQuery +{ + public int? Limit { get; set; } + public string? AfterSessionId { get; set; } +} + +public sealed class GameHistoryListPage +{ + public GameHistoryListPage(IEnumerable sessions, string? nextSessionId) + { + var copied = (sessions ?? throw new ArgumentNullException(nameof(sessions))).ToArray(); + if (copied.Any(session => session is null)) + { + throw new ArgumentException("A history list cannot contain null sessions.", nameof(sessions)); + } + + Sessions = Array.AsReadOnly(copied); + NextSessionId = nextSessionId; + } + + public IReadOnlyList Sessions { get; } + public string? NextSessionId { get; } +} + +public enum GameHistoryForkScope +{ + Branch, + Tree, +} + +public enum GameHistoryForkPosition +{ + Before, + At, +} + +public sealed class GameHistoryForkOptions +{ + public string? Id { get; set; } + public GameHistoryForkScope Scope { get; set; } = GameHistoryForkScope.Branch; + public string? EntryId { get; set; } + public GameHistoryForkPosition Position { get; set; } = GameHistoryForkPosition.At; + public string? ParentSessionId { get; set; } + public string? MetadataJson { get; set; } + public long? ExpectedSourceSequence { get; set; } +} + +public sealed class GameHistorySearchCursor +{ + public GameHistorySearchCursor(string sessionId, long entrySequence) + { + SessionId = GameHistoryObjectValidation.Required(sessionId, nameof(sessionId)); + EntrySequence = GameHistoryObjectValidation.Sequence(entrySequence, nameof(entrySequence)); + } + + public string SessionId { get; } + public long EntrySequence { get; } +} + +public sealed class GameHistorySearchQuery +{ + public GameHistorySearchQuery(string text) + { + Text = GameHistoryObjectValidation.Required(text, nameof(text)); + } + + public string Text { get; } + public string? SessionId { get; set; } + public string? EntryType { get; set; } + public int? Limit { get; set; } + public GameHistorySearchCursor? Cursor { get; set; } +} + +public sealed class GameHistorySearchHit +{ + public GameHistorySearchHit(GameHistoryMetadata session, GameHistoryEntry entry, string snippet) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + Entry = entry ?? throw new ArgumentNullException(nameof(entry)); + Snippet = snippet ?? throw new ArgumentNullException(nameof(snippet)); + } + + public GameHistoryMetadata Session { get; } + public GameHistoryEntry Entry { get; } + public string Snippet { get; } +} + +public sealed class GameHistorySearchPage +{ + public GameHistorySearchPage(IEnumerable hits, GameHistorySearchCursor? nextCursor) + { + var copied = (hits ?? throw new ArgumentNullException(nameof(hits))).ToArray(); + if (copied.Any(hit => hit is null)) + { + throw new ArgumentException("A search page cannot contain null hits.", nameof(hits)); + } + + Hits = Array.AsReadOnly(copied); + NextCursor = nextCursor; + } + + public IReadOnlyList Hits { get; } + public GameHistorySearchCursor? NextCursor { get; } +} + +public sealed class GameHistoryCommit +{ + public GameHistoryCommit(string mutationId, long sequence, bool replayed) + { + MutationId = GameHistoryObjectValidation.Required(mutationId, nameof(mutationId)); + Sequence = GameHistoryObjectValidation.Sequence(sequence, nameof(sequence)); + Replayed = replayed; + } + + public string MutationId { get; } + public long Sequence { get; } + public bool Replayed { get; } +} + +public sealed class GameHistoryEntryCommit +{ + public GameHistoryEntryCommit(GameHistoryEntry entry, GameHistoryCommit commit) + { + Entry = entry ?? throw new ArgumentNullException(nameof(entry)); + Commit = commit ?? throw new ArgumentNullException(nameof(commit)); + } + + public GameHistoryEntry Entry { get; } + public GameHistoryCommit Commit { get; } +} + +public sealed class GameHistoryRecordCommit +{ + public GameHistoryRecordCommit(GameHistoryRecord record, GameHistoryCommit commit) + { + Record = record ?? throw new ArgumentNullException(nameof(record)); + Commit = commit ?? throw new ArgumentNullException(nameof(commit)); + } + + public GameHistoryRecord Record { get; } + public GameHistoryCommit Commit { get; } +} + +public sealed class GameHistoryContextProjection +{ + public GameHistoryContextProjection(IEnumerable messages, string? stateJson = null) + { + var copied = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); + if (copied.Any(message => message is null)) + { + throw new ArgumentException("A context projection cannot contain null messages.", nameof(messages)); + } + + Messages = Array.AsReadOnly(copied); + StateJson = stateJson is null ? null : GameHistoryObjectValidation.Json(stateJson, nameof(stateJson)); + } + + public IReadOnlyList Messages { get; } + public string? StateJson { get; } +} + +public sealed class GameHistoryContextOptions +{ + public Func, IReadOnlyList>? EntryTransform { get; set; } + public Func>? EntryProjector { get; set; } + public Func, string?>? StateProjector { get; set; } + public int? MaxMessages { get; set; } + public TimeSpan CallbackTimeout { get; set; } = TimeSpan.FromSeconds(30); +} + +public static class GameHistoryContextTransforms +{ + public static Func, IReadOnlyList> AfterLatest(string type) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("An entry type is required.", nameof(type)); + } + + return entries => + { + for (var index = entries.Count - 1; index >= 0; index--) + { + if (string.Equals(entries[index].Type, type, StringComparison.Ordinal)) + { + return Array.AsReadOnly(entries.Skip(index).ToArray()); + } + } + + return Array.AsReadOnly(entries.ToArray()); + }; + } +} + +public interface IGameSessionHistoryRepository +{ + Task CreateAsync(GameHistoryCreateOptions? options = null, CancellationToken cancellationToken = default); + Task OpenAsync(string sessionId, CancellationToken cancellationToken = default); + Task ListAsync(GameHistoryListQuery? query = null, CancellationToken cancellationToken = default); + Task DeleteAsync(string sessionId, CancellationToken cancellationToken = default); + Task ForkAsync(string sourceSessionId, GameHistoryForkOptions? options = null, CancellationToken cancellationToken = default); + Task SearchAsync(GameHistorySearchQuery query, CancellationToken cancellationToken = default); +} + +public interface IGameSessionHistoryStorage +{ + Task GetMetadataAsync(CancellationToken cancellationToken); + Task> GetLanesAsync(CancellationToken cancellationToken); + Task GetEntryAsync(string id, CancellationToken cancellationToken); + Task> FindEntriesAsync(GameHistoryEntryQuery query, CancellationToken cancellationToken); + Task> FindBranchAsync(string lane, GameHistoryBranchQuery query, CancellationToken cancellationToken); + Task> FindRecordsAsync(GameHistoryRecordQuery query, CancellationToken cancellationToken); + Task> GetLogAsync(GameHistoryLogQuery query, CancellationToken cancellationToken); + Task GetNameAsync(CancellationToken cancellationToken); + Task GetLabelAsync(string entryId, CancellationToken cancellationToken); + Task GetStatsAsync(CancellationToken cancellationToken); + Task AppendEntryAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken); + Task AppendRecordAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken); + Task CreateLaneAsync(string lane, string? atEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken); + Task MoveLaneAsync(string lane, string? toEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken); + Task SetNameAsync(string name, string mutationId, long? expectedSequence, CancellationToken cancellationToken); + Task SetLabelAsync(string entryId, string? label, string mutationId, long? expectedSequence, CancellationToken cancellationToken); +} + +public sealed class GameSessionHistory +{ + private readonly IGameSessionHistoryStorage _storage; + private readonly GameHistoryLimits _limits; + private readonly string _lane; + + public GameSessionHistory(IGameSessionHistoryStorage storage, GameHistoryLimits? limits = null) + : this(storage, limits, "main") + { + } + + private GameSessionHistory(IGameSessionHistoryStorage storage, GameHistoryLimits? limits, string lane) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _limits = (limits ?? new GameHistoryLimits()).CopyAndValidate(); + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + _lane = lane; + } + + public GameSessionHistory View(string lane) + { + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + return new GameSessionHistory(_storage, _limits, lane); + } + + public async Task GetMetadataAsync(CancellationToken cancellationToken = default) + { + var metadata = await _storage.GetMetadataAsync(cancellationToken).ConfigureAwait(false) + ?? throw new GameHistoryException(GameHistoryErrorCode.Storage, "The history storage returned null metadata."); + GameHistoryValidation.SessionId(metadata.Id, nameof(metadata.Id), _limits); + GameHistoryValidation.OptionalIdentifier(metadata.ParentSessionId, nameof(metadata.ParentSessionId), _limits); + if (metadata.MetadataJson is not null) + { + GameHistoryValidation.JsonObject(metadata.MetadataJson, nameof(metadata.MetadataJson), _limits.MaxPayloadCharacters); + } + + return metadata; + } + + public async Task> GetLanesAsync(CancellationToken cancellationToken = default) + { + var lanes = await _storage.GetLanesAsync(cancellationToken).ConfigureAwait(false) + ?? throw new GameHistoryException(GameHistoryErrorCode.Storage, "The history storage returned null lanes."); + if (lanes.Count < 1 || lanes.Count > _limits.MaxLanesPerSession) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history storage returned an invalid lane count."); + } + + var names = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < lanes.Count; index++) + { + var lane = lanes[index] ?? throw new GameHistoryException(GameHistoryErrorCode.Storage, "The history storage returned a null lane."); + GameHistoryValidation.Identifier(lane.Name, nameof(lane.Name), _limits); + GameHistoryValidation.OptionalIdentifier(lane.LeafEntryId, nameof(lane.LeafEntryId), _limits); + if (!names.Add(lane.Name)) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, $"The history storage returned duplicate lane {lane.Name}."); + } + } + + return lanes; + } + + public async Task GetLeafEntryIdAsync(CancellationToken cancellationToken = default) + { + var lanes = await GetLanesAsync(cancellationToken).ConfigureAwait(false); + var lane = lanes.SingleOrDefault(candidate => string.Equals(candidate.Name, _lane, StringComparison.Ordinal)); + if (lane is null) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidLane, $"History lane not found: {_lane}."); + } + + return lane.LeafEntryId; + } + + public Task GetEntryAsync(string id, CancellationToken cancellationToken = default) + { + GameHistoryValidation.Identifier(id, nameof(id), _limits); + return _storage.GetEntryAsync(id, cancellationToken); + } + + public async Task> FindEntriesAsync( + GameHistoryEntryQuery? query = null, + CancellationToken cancellationToken = default) + { + query ??= new GameHistoryEntryQuery(); + GameHistoryValidation.EntryQuery(query, _limits); + return await BoundedPageAsync( + _storage.FindEntriesAsync(query, cancellationToken), + query.Limit ?? _limits.DefaultQueryResults).ConfigureAwait(false); + } + + public async Task> FindBranchAsync( + string? lane = null, + GameHistoryBranchQuery? query = null, + CancellationToken cancellationToken = default) + { + lane ??= _lane; + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + query ??= new GameHistoryBranchQuery(); + GameHistoryValidation.BranchQuery(query, _limits); + return await BoundedPageAsync( + _storage.FindBranchAsync(lane, query, cancellationToken), + query.Limit ?? _limits.DefaultQueryResults).ConfigureAwait(false); + } + + public async Task> FindRecordsAsync( + GameHistoryRecordQuery? query = null, + CancellationToken cancellationToken = default) + { + query ??= new GameHistoryRecordQuery(); + GameHistoryValidation.RecordQuery(query, _limits); + return await BoundedPageAsync( + _storage.FindRecordsAsync(query, cancellationToken), + query.Limit ?? _limits.DefaultQueryResults).ConfigureAwait(false); + } + + public async Task> GetLogAsync( + GameHistoryLogQuery? query = null, + CancellationToken cancellationToken = default) + { + query ??= new GameHistoryLogQuery(); + GameHistoryValidation.LogQuery(query, _limits); + return await BoundedPageAsync( + _storage.GetLogAsync(query, cancellationToken), + query.Limit ?? _limits.DefaultQueryResults).ConfigureAwait(false); + } + + public async Task GetNameAsync(CancellationToken cancellationToken = default) + { + var name = await _storage.GetNameAsync(cancellationToken).ConfigureAwait(false); + if (name is not null) + { + GameHistoryValidation.Fact(name, nameof(name), _limits); + } + + return name; + } + + public async Task GetLabelAsync(string entryId, CancellationToken cancellationToken = default) + { + GameHistoryValidation.Identifier(entryId, nameof(entryId), _limits); + var label = await _storage.GetLabelAsync(entryId, cancellationToken).ConfigureAwait(false); + if (label is not null) + { + GameHistoryValidation.Fact(label, nameof(label), _limits); + } + + return label; + } + + public async Task GetStatsAsync(CancellationToken cancellationToken = default) + { + var stats = await _storage.GetStatsAsync(cancellationToken).ConfigureAwait(false) + ?? throw new GameHistoryException(GameHistoryErrorCode.Storage, "The history storage returned null statistics."); + if (stats.EntryCount > _limits.MaxEntriesPerSession + || stats.RecordCount > _limits.MaxRecordsPerSession + || stats.LaneCount > _limits.MaxLanesPerSession + || stats.MutationCount > _limits.MaxMutationsPerSession + || stats.LastSequence != stats.MutationCount) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, "The history storage returned inconsistent statistics."); + } + + return stats; + } + + public Task AppendEntryAsync( + string id, + string type, + string payloadJson, + string? lane = null, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + lane ??= _lane; + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + GameHistoryValidation.Identifier(id, nameof(id), _limits); + GameHistoryValidation.Type(type, nameof(type), _limits); + GameHistoryValidation.Json(payloadJson, nameof(payloadJson), _limits.MaxPayloadCharacters); + var operation = GameHistoryValidation.MutationId(mutationId, _limits); + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.AppendEntryAsync(lane, id, type, payloadJson, operation, expectedSequence, cancellationToken); + } + + public Task AppendRecordAsync( + string id, + string type, + string payloadJson, + string? lane = null, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + lane ??= _lane; + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + GameHistoryValidation.Identifier(id, nameof(id), _limits); + GameHistoryValidation.Type(type, nameof(type), _limits); + GameHistoryValidation.Json(payloadJson, nameof(payloadJson), _limits.MaxPayloadCharacters); + var operation = GameHistoryValidation.MutationId(mutationId, _limits); + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.AppendRecordAsync(lane, id, type, payloadJson, operation, expectedSequence, cancellationToken); + } + + public Task CreateLaneAsync( + string lane, + string? atEntryId = null, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + GameHistoryValidation.OptionalIdentifier(atEntryId, nameof(atEntryId), _limits); + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.CreateLaneAsync(lane, atEntryId, GameHistoryValidation.MutationId(mutationId, _limits), expectedSequence, cancellationToken); + } + + public Task MoveLaneAsync( + string lane, + string? toEntryId, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + GameHistoryValidation.OptionalIdentifier(toEntryId, nameof(toEntryId), _limits); + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.MoveLaneAsync(lane, toEntryId, GameHistoryValidation.MutationId(mutationId, _limits), expectedSequence, cancellationToken); + } + + public Task SetNameAsync( + string name, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Fact(name, nameof(name), _limits); + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.SetNameAsync(name, GameHistoryValidation.MutationId(mutationId, _limits), expectedSequence, cancellationToken); + } + + public Task SetLabelAsync( + string entryId, + string? label, + string? mutationId = null, + long? expectedSequence = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Identifier(entryId, nameof(entryId), _limits); + if (label is not null) + { + GameHistoryValidation.Fact(label, nameof(label), _limits); + } + + GameHistoryValidation.ExpectedSequence(expectedSequence); + return _storage.SetLabelAsync(entryId, label, GameHistoryValidation.MutationId(mutationId, _limits), expectedSequence, cancellationToken); + } + + public async Task BuildContextAsync( + string lane, + GameHistoryContextOptions options, + CancellationToken cancellationToken = default) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + GameHistoryValidation.Identifier(lane, nameof(lane), _limits); + var maxMessages = options.MaxMessages ?? _limits.MaxContextMessages; + if (maxMessages < 1 || maxMessages > _limits.MaxContextMessages) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The context message limit is invalid."); + } + + if (options.CallbackTimeout < TimeSpan.FromMilliseconds(1) + || options.CallbackTimeout > TimeSpan.FromMinutes(5)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The context callback timeout is invalid."); + } + + var page = await _storage.FindBranchAsync( + lane, + new GameHistoryBranchQuery { Order = GameHistoryOrder.OldestFirst, Limit = _limits.MaxQueryResults }, + cancellationToken).ConfigureAwait(false); + IReadOnlyList entries = page.Items; + if (entries.Count > _limits.MaxQueryResults || page.NextSequence is not null) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The branch is too large for a single context projection."); + } + + if (options.EntryTransform is not null) + { + entries = await InvokeCallbackAsync( + () => options.EntryTransform(entries), + options.CallbackTimeout, + cancellationToken).ConfigureAwait(false) ?? throw new GameHistoryException( + GameHistoryErrorCode.InvalidInput, + "The context transform returned null."); + if (entries.Count > _limits.MaxQueryResults) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The context transform returned too many entries."); + } + + for (var index = 0; index < entries.Count; index++) + { + if (entries[index] is null) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, "The context transform returned a null entry."); + } + } + } + + var messages = new List(); + if (options.EntryProjector is not null) + { + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var projected = await InvokeCallbackAsync( + () => options.EntryProjector(entry), + options.CallbackTimeout, + cancellationToken).ConfigureAwait(false) ?? throw new GameHistoryException( + GameHistoryErrorCode.InvalidInput, + "The context projector returned null."); + if (projected.Count > maxMessages - messages.Count) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The context contains too many messages."); + } + + for (var index = 0; index < projected.Count; index++) + { + var message = projected[index]; + if (message is null) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, "The context projector returned a null message."); + } + + messages.Add(message); + } + } + } + + var stateJson = options.StateProjector is null + ? null + : await InvokeCallbackAsync( + () => options.StateProjector(entries), + options.CallbackTimeout, + cancellationToken).ConfigureAwait(false); + if (stateJson is not null) + { + GameHistoryValidation.Json(stateJson, nameof(stateJson), _limits.MaxContextStateCharacters); + } + + return new GameHistoryContextProjection(messages, stateJson); + } + + public Task BuildContextAsync( + GameHistoryContextOptions options, + CancellationToken cancellationToken = default) => BuildContextAsync(_lane, options, cancellationToken); + + private static async Task InvokeCallbackAsync( + Func callback, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var callbackTask = Task.Run(callback); + var timeoutTask = Task.Delay(timeout, cancellationToken); + var completed = await Task.WhenAny(callbackTask, timeoutTask).ConfigureAwait(false); + if (completed == callbackTask) + { + return await callbackTask.ConfigureAwait(false); + } + + _ = callbackTask.ContinueWith( + task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + cancellationToken.ThrowIfCancellationRequested(); + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "A context callback exceeded its timeout."); + } + + private static async Task> BoundedPageAsync(Task> operation, int limit) + { + var page = await operation.ConfigureAwait(false) + ?? throw new GameHistoryException(GameHistoryErrorCode.Storage, "The history storage returned a null page."); + if (page.Items.Count > limit) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history storage exceeded the requested page size."); + } + + return page; + } +} + +public sealed class InMemoryGameSessionHistoryRepository : IGameSessionHistoryRepository +{ + private readonly object _sync = new(); + private readonly Dictionary _sessions = new(StringComparer.Ordinal); + private readonly GameHistoryLimits _limits; + + public InMemoryGameSessionHistoryRepository(GameHistoryLimits? limits = null) + { + _limits = (limits ?? new GameHistoryLimits()).CopyAndValidate(); + } + + public Task CreateAsync(GameHistoryCreateOptions? options = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + options ??= new GameHistoryCreateOptions(); + var id = options.Id ?? Guid.NewGuid().ToString("N"); + GameHistoryValidation.SessionId(id, nameof(options.Id), _limits); + GameHistoryValidation.OptionalIdentifier(options.ParentSessionId, nameof(options.ParentSessionId), _limits); + if (options.MetadataJson is not null) + { + GameHistoryValidation.JsonObject(options.MetadataJson, nameof(options.MetadataJson), _limits.MaxPayloadCharacters); + } + + lock (_sync) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_sessions.ContainsKey(id)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History session already exists: {id}."); + } + + if (_sessions.Count >= _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository is full."); + } + + var now = DateTimeOffset.UtcNow; + var storage = new InMemoryGameSessionHistoryStorage( + new GameHistoryMetadata(id, now, options.ParentSessionId, options.MetadataJson, now), + _limits); + _sessions.Add(id, storage); + return Task.FromResult(new GameSessionHistory(storage, _limits)); + } + } + + public Task OpenAsync(string sessionId, CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sessionId, nameof(sessionId), _limits); + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + if (!_sessions.TryGetValue(sessionId, out var storage)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, $"History session not found: {sessionId}."); + } + + return Task.FromResult(new GameSessionHistory(storage, _limits)); + } + } + + public Task ListAsync(GameHistoryListQuery? query = null, CancellationToken cancellationToken = default) + { + query ??= new GameHistoryListQuery(); + var limit = GameHistoryValidation.Limit(query.Limit, _limits); + if (query.AfterSessionId is not null) + { + GameHistoryValidation.SessionId(query.AfterSessionId, nameof(query.AfterSessionId), _limits); + } + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + var all = _sessions.Values + .Select(storage => storage.Metadata) + .OrderByDescending(metadata => metadata.ModifiedAt) + .ThenBy(metadata => metadata.Id, StringComparer.Ordinal) + .ToArray(); + var start = CursorStart(all, query.AfterSessionId); + var items = all.Skip(start).Take(limit).ToArray(); + var next = start + items.Length < all.Length ? items.LastOrDefault()?.Id : null; + return Task.FromResult(new GameHistoryListPage(items, next)); + } + } + + public Task DeleteAsync(string sessionId, CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sessionId, nameof(sessionId), _limits); + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + _sessions.Remove(sessionId); + return Task.CompletedTask; + } + } + + public Task ForkAsync( + string sourceSessionId, + GameHistoryForkOptions? options = null, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.SessionId(sourceSessionId, nameof(sourceSessionId), _limits); + options ??= new GameHistoryForkOptions(); + GameHistoryValidation.Fork(options, _limits); + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + if (!_sessions.TryGetValue(sourceSessionId, out var source)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, $"History session not found: {sourceSessionId}."); + } + + var id = options.Id ?? Guid.NewGuid().ToString("N"); + GameHistoryValidation.SessionId(id, nameof(options.Id), _limits); + if (_sessions.ContainsKey(id)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History session already exists: {id}."); + } + + if (_sessions.Count >= _limits.MaxSessions) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history repository is full."); + } + + var state = source.CopyForFork(options); + var now = DateTimeOffset.UtcNow; + var metadata = new GameHistoryMetadata( + id, + now, + options.ParentSessionId ?? sourceSessionId, + options.MetadataJson, + now); + var target = new InMemoryGameSessionHistoryStorage(metadata, _limits, state); + _sessions.Add(id, target); + return Task.FromResult(new GameSessionHistory(target, _limits)); + } + } + + public async Task SearchAsync( + GameHistorySearchQuery query, + CancellationToken cancellationToken = default) + { + GameHistoryValidation.Search(query, _limits); + var limit = query.Limit ?? Math.Min(_limits.DefaultQueryResults, _limits.MaxSearchResults); + GameHistoryMetadata[] sessions; + lock (_sync) + { + sessions = _sessions.Values.Select(storage => storage.Metadata) + .Where(metadata => query.SessionId is null || string.Equals(metadata.Id, query.SessionId, StringComparison.Ordinal)) + .OrderBy(metadata => metadata.Id, StringComparer.Ordinal) + .ToArray(); + } + + var hits = new List(); + var scannedEntries = 0; + var cursorPassed = query.Cursor is null; + foreach (var metadata in sessions) + { + cancellationToken.ThrowIfCancellationRequested(); + var history = await OpenAsync(metadata.Id, cancellationToken).ConfigureAwait(false); + long? cursor = null; + while (true) + { + var page = await history.FindEntriesAsync( + new GameHistoryEntryQuery + { + Type = query.EntryType, + Order = GameHistoryOrder.OldestFirst, + Limit = _limits.MaxQueryResults, + CursorSequence = cursor, + }, + cancellationToken).ConfigureAwait(false); + foreach (var entry in page.Items) + { + if (++scannedEntries > _limits.MaxSearchScannedEntries) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The search scan limit was exceeded."); + } + + if (!cursorPassed) + { + cursorPassed = string.CompareOrdinal(metadata.Id, query.Cursor!.SessionId) > 0 + || (string.Equals(metadata.Id, query.Cursor.SessionId, StringComparison.Ordinal) + && entry.Sequence > query.Cursor.EntrySequence); + if (!cursorPassed) + { + continue; + } + } + + if (!entry.Type.Contains(query.Text, StringComparison.OrdinalIgnoreCase) + && !entry.PayloadJson.Contains(query.Text, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var snippet = entry.PayloadJson.Length <= 512 ? entry.PayloadJson : entry.PayloadJson.Substring(0, 512); + hits.Add(new GameHistorySearchHit(metadata, entry, snippet)); + if (hits.Count == limit) + { + return new GameHistorySearchPage(hits, new GameHistorySearchCursor(metadata.Id, entry.Sequence)); + } + } + + if (page.NextSequence is null) + { + break; + } + + cursor = page.NextSequence; + } + } + + return new GameHistorySearchPage(hits, null); + } + + private static int CursorStart(IReadOnlyList values, string? afterId) + { + if (afterId is null) + { + return 0; + } + + for (var index = 0; index < values.Count; index++) + { + if (string.Equals(values[index].Id, afterId, StringComparison.Ordinal)) + { + return index + 1; + } + } + + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The list cursor does not identify a visible session."); + } +} + +internal sealed class InMemoryGameSessionHistoryStorage : IGameSessionHistoryStorage +{ + private readonly object _sync = new(); + private readonly GameHistoryLimits _limits; + private GameHistoryMetadata _metadata; + private readonly GameHistoryState _state; + + internal InMemoryGameSessionHistoryStorage( + GameHistoryMetadata metadata, + GameHistoryLimits limits, + GameHistoryState? state = null) + { + _metadata = metadata; + _limits = limits; + _state = state ?? new GameHistoryState(limits); + } + + internal GameHistoryMetadata Metadata + { + get + { + lock (_sync) + { + return _metadata; + } + } + } + + internal GameHistoryState CopyForFork(GameHistoryForkOptions options) + { + lock (_sync) + { + if (options.ExpectedSourceSequence is { } expected && expected != _state.Sequence) + { + throw new GameHistoryConcurrencyException(expected, _state.Sequence); + } + + return _state.CopyForFork(options); + } + } + + public Task GetMetadataAsync(CancellationToken cancellationToken) => + Read(state => _metadata, cancellationToken); + + public Task> GetLanesAsync(CancellationToken cancellationToken) => + Read(state => state.GetLanes(), cancellationToken); + + public Task GetEntryAsync(string id, CancellationToken cancellationToken) => + Read(state => state.GetEntry(id), cancellationToken); + + public Task> FindEntriesAsync(GameHistoryEntryQuery query, CancellationToken cancellationToken) => + Read(state => state.FindEntries(query), cancellationToken); + + public Task> FindBranchAsync(string lane, GameHistoryBranchQuery query, CancellationToken cancellationToken) => + Read(state => state.FindBranch(lane, query), cancellationToken); + + public Task> FindRecordsAsync(GameHistoryRecordQuery query, CancellationToken cancellationToken) => + Read(state => state.FindRecords(query), cancellationToken); + + public Task> GetLogAsync(GameHistoryLogQuery query, CancellationToken cancellationToken) => + Read(state => state.GetLog(query), cancellationToken); + + public Task GetNameAsync(CancellationToken cancellationToken) => Read(state => state.Name, cancellationToken); + + public Task GetLabelAsync(string entryId, CancellationToken cancellationToken) => + Read(state => state.GetLabel(entryId), cancellationToken); + + public Task GetStatsAsync(CancellationToken cancellationToken) => + Read(state => state.GetStats(), cancellationToken); + + public Task AppendEntryAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.AppendEntry(lane, id, type, payloadJson, mutationId, expectedSequence, DateTimeOffset.UtcNow), cancellationToken); + + public Task AppendRecordAsync(string lane, string id, string type, string payloadJson, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.AppendRecord(lane, id, type, payloadJson, mutationId, expectedSequence, DateTimeOffset.UtcNow), cancellationToken); + + public Task CreateLaneAsync(string lane, string? atEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.CreateLane(lane, atEntryId, mutationId, expectedSequence), cancellationToken); + + public Task MoveLaneAsync(string lane, string? toEntryId, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.MoveLane(lane, toEntryId, mutationId, expectedSequence), cancellationToken); + + public Task SetNameAsync(string name, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.SetName(name, mutationId, expectedSequence), cancellationToken); + + public Task SetLabelAsync(string entryId, string? label, string mutationId, long? expectedSequence, CancellationToken cancellationToken) => + Write(state => state.SetLabel(entryId, label, mutationId, expectedSequence), cancellationToken); + + private Task Read(Func read, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(read(_state)); + } + } + + private Task Write(Func write, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_sync) + { + cancellationToken.ThrowIfCancellationRequested(); + var value = write(_state); + _metadata = new GameHistoryMetadata( + _metadata.Id, + _metadata.CreatedAt, + _metadata.ParentSessionId, + _metadata.MetadataJson, + DateTimeOffset.UtcNow); + return Task.FromResult(value); + } + } +} + +internal sealed class GameHistoryState +{ + private readonly GameHistoryLimits _limits; + private readonly List _entries = new(); + private readonly Dictionary _entriesById = new(StringComparer.Ordinal); + private readonly List _records = new(); + private readonly HashSet _entityIds = new(StringComparer.Ordinal); + private readonly Dictionary _lanes = new(StringComparer.Ordinal) { ["main"] = null }; + private readonly List _log = new(); + private readonly Dictionary _logByMutation = new(StringComparer.Ordinal); + private readonly Dictionary _labels = new(StringComparer.Ordinal); + + internal GameHistoryState(GameHistoryLimits limits) + { + _limits = limits; + } + + internal long Sequence { get; private set; } + internal string? Name { get; private set; } + + internal IReadOnlyList GetLanes() => + Array.AsReadOnly(_lanes.Select(pair => new GameHistoryLane(pair.Key, pair.Value)).ToArray()); + + internal GameHistoryEntry? GetEntry(string id) => _entriesById.TryGetValue(id, out var entry) ? entry : null; + + internal string? GetLabel(string id) => _labels.TryGetValue(id, out var label) ? label : null; + + internal GameHistoryStats GetStats() => new(_entries.Count, _records.Count, _lanes.Count, _log.Count, Sequence); + + internal GameHistoryPage FindEntries(GameHistoryEntryQuery query) + { + var limit = query.Limit ?? _limits.DefaultQueryResults; + IEnumerable source = query.Order == GameHistoryOrder.OldestFirst ? _entries : _entries.AsEnumerable().Reverse(); + source = source.Where(entry => + (query.Type is null || string.Equals(entry.Type, query.Type, StringComparison.Ordinal)) + && (query.CursorSequence is null + || (query.Order == GameHistoryOrder.OldestFirst + ? entry.Sequence > query.CursorSequence + : entry.Sequence < query.CursorSequence))); + return Page(source, limit, entry => entry.Sequence); + } + + internal GameHistoryPage FindBranch(string lane, GameHistoryBranchQuery query) + { + if (!_lanes.TryGetValue(lane, out var leaf)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidLane, $"History lane not found: {lane}."); + } + + var start = query.StartEntryId ?? leaf; + if (start is null) + { + return new GameHistoryPage(Array.Empty(), null); + } + + if (!_entriesById.TryGetValue(start, out var current)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, $"History entry not found: {start}."); + } + + var path = new List(); + var visited = new HashSet(StringComparer.Ordinal); + while (true) + { + if (!visited.Add(current.Id)) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, $"History branch contains a cycle at {current.Id}."); + } + + path.Add(current); + if (string.Equals(current.Id, query.StopAtEntryId, StringComparison.Ordinal) + || string.Equals(current.Type, query.StopAtType, StringComparison.Ordinal) + || current.ParentId is null) + { + break; + } + + if (!_entriesById.TryGetValue(current.ParentId, out current!)) + { + throw new GameHistoryException(GameHistoryErrorCode.CorruptStorage, $"History parent is missing: {current.ParentId}."); + } + } + + IEnumerable source = query.Order == GameHistoryOrder.OldestFirst ? path.AsEnumerable().Reverse() : path; + source = source.Where(entry => + (query.Type is null || string.Equals(entry.Type, query.Type, StringComparison.Ordinal)) + && (query.CursorSequence is null + || (query.Order == GameHistoryOrder.OldestFirst + ? entry.Sequence > query.CursorSequence + : entry.Sequence < query.CursorSequence))); + return Page(source, query.Limit ?? _limits.DefaultQueryResults, entry => entry.Sequence); + } + + internal GameHistoryPage FindRecords(GameHistoryRecordQuery query) + { + IEnumerable source = query.Order == GameHistoryOrder.OldestFirst ? _records : _records.AsEnumerable().Reverse(); + source = source.Where(record => + (query.Lane is null || string.Equals(record.Lane, query.Lane, StringComparison.Ordinal)) + && (query.Type is null || string.Equals(record.Type, query.Type, StringComparison.Ordinal)) + && (query.CursorSequence is null + || (query.Order == GameHistoryOrder.OldestFirst + ? record.Sequence > query.CursorSequence + : record.Sequence < query.CursorSequence))); + return Page(source, query.Limit ?? _limits.DefaultQueryResults, record => record.Sequence); + } + + internal GameHistoryPage GetLog(GameHistoryLogQuery query) + { + var source = _log.Where(item => item.Sequence > query.AfterSequence); + return Page(source, query.Limit ?? _limits.DefaultQueryResults, item => item.Sequence); + } + + internal GameHistoryEntryCommit AppendEntry( + string lane, + string id, + string type, + string payloadJson, + string mutationId, + long? expectedSequence, + DateTimeOffset timestamp) + { + if (TryReplay(mutationId, out var replay)) + { + if (replay.Kind != GameHistoryMutationKind.Entry + || replay.Entry is null + || !string.Equals(replay.Lane, lane, StringComparison.Ordinal) + || !string.Equals(replay.Entry.Id, id, StringComparison.Ordinal) + || !string.Equals(replay.Entry.Type, type, StringComparison.Ordinal) + || !string.Equals(replay.Entry.PayloadJson, payloadJson, StringComparison.Ordinal)) + { + throw MutationMismatch(mutationId); + } + + return new GameHistoryEntryCommit(replay.Entry, new GameHistoryCommit(mutationId, replay.Sequence, true)); + } + + CheckExpected(expectedSequence); + CheckCapacity(_entries.Count, _limits.MaxEntriesPerSession, "entry"); + CheckMutationCapacity(); + if (!_lanes.TryGetValue(lane, out var parentId)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidLane, $"History lane not found: {lane}."); + } + + CheckEntityId(id); + var entry = new GameHistoryEntry(id, Sequence + 1, parentId, timestamp, type, payloadJson); + var item = new GameHistoryLogItem(mutationId, entry.Sequence, GameHistoryMutationKind.Entry, entry: entry, lane: lane); + Apply(item, replay: false); + return new GameHistoryEntryCommit(entry, new GameHistoryCommit(mutationId, entry.Sequence, false)); + } + + internal GameHistoryRecordCommit AppendRecord( + string lane, + string id, + string type, + string payloadJson, + string mutationId, + long? expectedSequence, + DateTimeOffset timestamp) + { + if (TryReplay(mutationId, out var replay)) + { + if (replay.Kind != GameHistoryMutationKind.Record + || replay.Record is null + || !string.Equals(replay.Record.Lane, lane, StringComparison.Ordinal) + || !string.Equals(replay.Record.Id, id, StringComparison.Ordinal) + || !string.Equals(replay.Record.Type, type, StringComparison.Ordinal) + || !string.Equals(replay.Record.PayloadJson, payloadJson, StringComparison.Ordinal)) + { + throw MutationMismatch(mutationId); + } + + return new GameHistoryRecordCommit(replay.Record, new GameHistoryCommit(mutationId, replay.Sequence, true)); + } + + CheckExpected(expectedSequence); + CheckCapacity(_records.Count, _limits.MaxRecordsPerSession, "record"); + CheckMutationCapacity(); + if (!_lanes.ContainsKey(lane)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidLane, $"History lane not found: {lane}."); + } + + CheckEntityId(id); + var record = new GameHistoryRecord(id, Sequence + 1, timestamp, lane, type, payloadJson); + var item = new GameHistoryLogItem(mutationId, record.Sequence, GameHistoryMutationKind.Record, record: record, lane: lane); + Apply(item, replay: false); + return new GameHistoryRecordCommit(record, new GameHistoryCommit(mutationId, record.Sequence, false)); + } + + internal GameHistoryCommit CreateLane(string lane, string? at, string mutationId, long? expectedSequence) + { + if (TryReplayLane(mutationId, lane, at, createsLane: true, out var replay)) + { + return replay; + } + + CheckExpected(expectedSequence); + CheckMutationCapacity(); + if (_lanes.ContainsKey(lane)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History lane already exists: {lane}."); + } + + if (_lanes.Count >= _limits.MaxLanesPerSession) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history has too many lanes."); + } + + RequireEntry(at); + var item = new GameHistoryLogItem(mutationId, Sequence + 1, GameHistoryMutationKind.Lane, lane: lane, leafEntryId: at, createsLane: true); + Apply(item, replay: false); + return new GameHistoryCommit(mutationId, item.Sequence, false); + } + + internal GameHistoryCommit MoveLane(string lane, string? to, string mutationId, long? expectedSequence) + { + if (TryReplayLane(mutationId, lane, to, createsLane: false, out var replay)) + { + return replay; + } + + CheckExpected(expectedSequence); + CheckMutationCapacity(); + if (!_lanes.ContainsKey(lane)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidLane, $"History lane not found: {lane}."); + } + + RequireEntry(to); + var item = new GameHistoryLogItem(mutationId, Sequence + 1, GameHistoryMutationKind.Lane, lane: lane, leafEntryId: to, createsLane: false); + Apply(item, replay: false); + return new GameHistoryCommit(mutationId, item.Sequence, false); + } + + internal GameHistoryCommit SetName(string name, string mutationId, long? expectedSequence) + { + if (TryReplayFact(mutationId, GameHistoryMutationKind.Name, name, null, out var replay)) + { + return replay; + } + + CheckExpected(expectedSequence); + CheckMutationCapacity(); + var item = new GameHistoryLogItem(mutationId, Sequence + 1, GameHistoryMutationKind.Name, name: name); + Apply(item, replay: false); + return new GameHistoryCommit(mutationId, item.Sequence, false); + } + + internal GameHistoryCommit SetLabel(string target, string? label, string mutationId, long? expectedSequence) + { + if (TryReplayFact(mutationId, GameHistoryMutationKind.Label, target, label, out var replay)) + { + return replay; + } + + CheckExpected(expectedSequence); + CheckMutationCapacity(); + RequireEntry(target); + var item = new GameHistoryLogItem( + mutationId, + Sequence + 1, + GameHistoryMutationKind.Label, + targetEntryId: target, + label: label); + Apply(item, replay: false); + return new GameHistoryCommit(mutationId, item.Sequence, false); + } + + internal void Replay(GameHistoryLogItem item) + { + CheckMutationCapacity(); + if (item.Kind == GameHistoryMutationKind.Entry) + { + CheckCapacity(_entries.Count, _limits.MaxEntriesPerSession, "entry"); + } + else if (item.Kind == GameHistoryMutationKind.Record) + { + CheckCapacity(_records.Count, _limits.MaxRecordsPerSession, "record"); + } + + if (item.Sequence != Sequence + 1) + { + throw Corrupt($"History sequence {item.Sequence} is not consecutive."); + } + + if (_logByMutation.ContainsKey(item.MutationId)) + { + throw Corrupt($"History contains duplicate mutation ID {item.MutationId}."); + } + + Apply(item, replay: true); + } + + internal GameHistoryState CopyForFork(GameHistoryForkOptions options) + { + IReadOnlyList entries; + IReadOnlyList lanes; + if (options.Scope == GameHistoryForkScope.Tree) + { + entries = _entries; + lanes = GetLanes(); + } + else + { + var selected = options.EntryId is null + ? (_lanes.TryGetValue("main", out var leaf) ? leaf : null) + : options.EntryId; + string? target = null; + if (selected is not null) + { + if (!_entriesById.TryGetValue(selected, out var entry)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidForkTarget, $"Fork entry not found: {selected}."); + } + + target = options.Position == GameHistoryForkPosition.At ? entry.Id : entry.ParentId; + } + + entries = target is null + ? Array.Empty() + : BuildPath(target).Reverse().ToArray(); + lanes = new[] { new GameHistoryLane("main", target) }; + } + + var copy = new GameHistoryState(_limits); + foreach (var entry in entries) + { + var cloned = new GameHistoryEntry(entry.Id, copy.Sequence + 1, entry.ParentId, entry.Timestamp, entry.Type, entry.PayloadJson); + copy.Replay(new GameHistoryLogItem($"fork-{cloned.Sequence}", cloned.Sequence, GameHistoryMutationKind.Entry, entry: cloned)); + } + + foreach (var lane in lanes) + { + copy.Replay(new GameHistoryLogItem($"fork-{copy.Sequence + 1}", copy.Sequence + 1, GameHistoryMutationKind.Lane, lane: lane.Name, leafEntryId: lane.LeafEntryId, createsLane: !string.Equals(lane.Name, "main", StringComparison.Ordinal))); + } + + if (Name is not null) + { + copy.Replay(new GameHistoryLogItem($"fork-{copy.Sequence + 1}", copy.Sequence + 1, GameHistoryMutationKind.Name, name: Name)); + } + + foreach (var entry in entries) + { + if (_labels.TryGetValue(entry.Id, out var label)) + { + copy.Replay(new GameHistoryLogItem($"fork-{copy.Sequence + 1}", copy.Sequence + 1, GameHistoryMutationKind.Label, targetEntryId: entry.Id, label: label)); + } + } + + return copy; + } + + internal IReadOnlyList ExportLog() => Array.AsReadOnly(_log.ToArray()); + + private IReadOnlyList BuildPath(string start) + { + var result = new List(); + var visited = new HashSet(StringComparer.Ordinal); + var currentId = start; + while (true) + { + if (!visited.Add(currentId) || !_entriesById.TryGetValue(currentId, out var current)) + { + throw Corrupt($"History branch is invalid at {currentId}."); + } + + result.Add(current); + if (current.ParentId is null) + { + return result; + } + + currentId = current.ParentId; + } + } + + private void Apply(GameHistoryLogItem item, bool replay) + { + if (item.Sequence != Sequence + 1) + { + throw Corrupt($"History sequence {item.Sequence} is not consecutive."); + } + + switch (item.Kind) + { + case GameHistoryMutationKind.Entry: + if (item.Entry is null || item.Entry.Sequence != item.Sequence) + { + throw Corrupt("History entry mutation is malformed."); + } + + if (!_lanes.TryGetValue(item.Lane ?? string.Empty, out var expectedParent) && item.Lane is not null) + { + throw Corrupt($"History entry references missing lane {item.Lane}."); + } + + if (item.Entry.ParentId is not null && !_entriesById.ContainsKey(item.Entry.ParentId)) + { + throw Corrupt($"History entry references missing parent {item.Entry.ParentId}."); + } + + if (item.Lane is not null && !string.Equals(expectedParent, item.Entry.ParentId, StringComparison.Ordinal)) + { + throw Corrupt("History entry does not chain to its lane leaf."); + } + + if (!_entityIds.Add(item.Entry.Id)) + { + throw Corrupt($"History contains duplicate entity ID {item.Entry.Id}."); + } + + _entries.Add(item.Entry); + _entriesById.Add(item.Entry.Id, item.Entry); + if (item.Lane is not null) + { + _lanes[item.Lane] = item.Entry.Id; + } + + break; + case GameHistoryMutationKind.Record: + if (item.Record is null || item.Record.Sequence != item.Sequence || !_lanes.ContainsKey(item.Record.Lane)) + { + throw Corrupt("History record mutation is malformed."); + } + + if (!_entityIds.Add(item.Record.Id)) + { + throw Corrupt($"History contains duplicate entity ID {item.Record.Id}."); + } + + _records.Add(item.Record); + break; + case GameHistoryMutationKind.Lane: + if (item.Lane is null || (item.LeafEntryId is not null && !_entriesById.ContainsKey(item.LeafEntryId))) + { + throw Corrupt("History lane mutation is malformed."); + } + + if (item.CreatesLane is null) + { + throw Corrupt("History lane mutation is missing its operation kind."); + } + + if (item.CreatesLane.Value && _lanes.ContainsKey(item.Lane)) + { + throw Corrupt($"History creates duplicate lane {item.Lane}."); + } + + if (!item.CreatesLane.Value && !_lanes.ContainsKey(item.Lane)) + { + throw Corrupt($"History moves missing lane {item.Lane}."); + } + + _lanes[item.Lane] = item.LeafEntryId; + break; + case GameHistoryMutationKind.Name: + if (item.Name is null) + { + throw Corrupt("History name mutation is malformed."); + } + + Name = item.Name; + break; + case GameHistoryMutationKind.Label: + if (item.TargetEntryId is null || !_entriesById.ContainsKey(item.TargetEntryId)) + { + throw Corrupt("History label mutation is malformed."); + } + + if (item.Label is null) + { + _labels.Remove(item.TargetEntryId); + } + else + { + _labels[item.TargetEntryId] = item.Label; + } + + break; + default: + throw Corrupt("History mutation kind is invalid."); + } + + Sequence = item.Sequence; + _log.Add(item); + _logByMutation.Add(item.MutationId, item); + if (!replay && _log.Count > _limits.MaxMutationsPerSession) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, "The history mutation limit was exceeded."); + } + } + + private bool TryReplay(string mutationId, out GameHistoryLogItem item) => _logByMutation.TryGetValue(mutationId, out item!); + + private bool TryReplayFact( + string mutationId, + GameHistoryMutationKind kind, + string first, + string? second, + out GameHistoryCommit commit) + { + if (!TryReplay(mutationId, out var item)) + { + commit = null!; + return false; + } + + var matches = kind switch + { + GameHistoryMutationKind.Name => item.Kind == kind && string.Equals(item.Name, first, StringComparison.Ordinal), + GameHistoryMutationKind.Label => item.Kind == kind + && string.Equals(item.TargetEntryId, first, StringComparison.Ordinal) + && string.Equals(item.Label, second, StringComparison.Ordinal), + _ => false, + }; + if (!matches) + { + throw MutationMismatch(mutationId); + } + + commit = new GameHistoryCommit(mutationId, item.Sequence, true); + return true; + } + + private bool TryReplayLane( + string mutationId, + string lane, + string? leafEntryId, + bool createsLane, + out GameHistoryCommit commit) + { + if (!TryReplay(mutationId, out var item)) + { + commit = null!; + return false; + } + + if (item.Kind != GameHistoryMutationKind.Lane + || item.CreatesLane != createsLane + || !string.Equals(item.Lane, lane, StringComparison.Ordinal) + || !string.Equals(item.LeafEntryId, leafEntryId, StringComparison.Ordinal)) + { + throw MutationMismatch(mutationId); + } + + commit = new GameHistoryCommit(mutationId, item.Sequence, true); + return true; + } + + private void CheckExpected(long? expected) + { + if (expected is not null && expected.Value != Sequence) + { + throw new GameHistoryConcurrencyException(expected.Value, Sequence); + } + } + + private void CheckMutationCapacity() + { + CheckCapacity(_log.Count, _limits.MaxMutationsPerSession, "mutation"); + } + + private static void CheckCapacity(int current, int limit, string kind) + { + if (current >= limit) + { + throw new GameHistoryException(GameHistoryErrorCode.LimitExceeded, $"The history {kind} limit was reached."); + } + } + + private void CheckEntityId(string id) + { + if (_entityIds.Contains(id)) + { + throw new GameHistoryException(GameHistoryErrorCode.AlreadyExists, $"History entity ID already exists: {id}."); + } + } + + private void RequireEntry(string? id) + { + if (id is not null && !_entriesById.ContainsKey(id)) + { + throw new GameHistoryException(GameHistoryErrorCode.NotFound, $"History entry not found: {id}."); + } + } + + private static GameHistoryException MutationMismatch(string id) => new( + GameHistoryErrorCode.Conflict, + $"Mutation ID {id} was already committed with different content."); + + private static GameHistoryException Corrupt(string message) => new(GameHistoryErrorCode.CorruptStorage, message); + + private static GameHistoryPage Page(IEnumerable source, int limit, Func sequence) + { + var values = source.Take(limit + 1).ToArray(); + var hasMore = values.Length > limit; + var items = hasMore ? values.Take(limit).ToArray() : values; + return new GameHistoryPage(items, hasMore && items.Length > 0 ? sequence(items[^1]) : null); + } +} + +internal static class GameHistoryValidation +{ + internal static void SessionId(string value, string name, GameHistoryLimits limits) + { + Identifier(value, name, limits); + if (!char.IsLetterOrDigit(value[0]) + || !char.IsLetterOrDigit(value[value.Length - 1]) + || value.Any(character => !char.IsLetterOrDigit(character) && character is not '-' and not '_' and not '.')) + { + throw new GameHistoryException( + GameHistoryErrorCode.InvalidInput, + "A session ID must start and end with an alphanumeric character and contain only alphanumerics, '-', '_', and '.'."); + } + } + + internal static void Identifier(string value, string name, GameHistoryLimits limits) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > limits.MaxIdentifierCharacters + || value.Any(character => char.IsControl(character))) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} is not a valid bounded identifier."); + } + } + + internal static void OptionalIdentifier(string? value, string name, GameHistoryLimits limits) + { + if (value is not null) + { + Identifier(value, name, limits); + } + } + + internal static void Type(string value, string name, GameHistoryLimits limits) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > limits.MaxTypeCharacters || value.Any(char.IsControl)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} is not a valid bounded type."); + } + } + + internal static void Fact(string value, string name, GameHistoryLimits limits) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > limits.MaxFactCharacters || value.IndexOf('\0') >= 0) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} is not a valid bounded value."); + } + } + + internal static void Json(string value, string name, int maxCharacters) + { + if (value is null || value.Length > maxCharacters) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} is too large."); + } + + try + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + } + catch (JsonException exception) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} is not valid JSON.", exception); + } + } + + internal static void JsonObject(string value, string name, int maxCharacters) + { + Json(value, name, maxCharacters); + using var document = JsonDocument.Parse(value); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, $"{name} must be a JSON object."); + } + } + + internal static string MutationId(string? mutationId, GameHistoryLimits limits) + { + var value = mutationId ?? Guid.NewGuid().ToString("N"); + Identifier(value, nameof(mutationId), limits); + return value; + } + + internal static void ExpectedSequence(long? expected) + { + if (expected is < 0) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, "The expected sequence cannot be negative."); + } + } + + internal static int Limit(int? value, GameHistoryLimits limits) + { + var limit = value ?? limits.DefaultQueryResults; + if (limit < 1 || limit > limits.MaxQueryResults) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The query limit is invalid."); + } + + return limit; + } + + internal static void EntryQuery(GameHistoryEntryQuery query, GameHistoryLimits limits) + { + Order(query.Order); + Limit(query.Limit, limits); + OptionalCursor(query.CursorSequence); + if (query.Type is not null) + { + Type(query.Type, nameof(query.Type), limits); + } + } + + internal static void BranchQuery(GameHistoryBranchQuery query, GameHistoryLimits limits) + { + Order(query.Order); + Limit(query.Limit, limits); + OptionalCursor(query.CursorSequence); + OptionalIdentifier(query.StartEntryId, nameof(query.StartEntryId), limits); + OptionalIdentifier(query.StopAtEntryId, nameof(query.StopAtEntryId), limits); + if (query.Type is not null) Type(query.Type, nameof(query.Type), limits); + if (query.StopAtType is not null) Type(query.StopAtType, nameof(query.StopAtType), limits); + } + + internal static void RecordQuery(GameHistoryRecordQuery query, GameHistoryLimits limits) + { + Order(query.Order); + Limit(query.Limit, limits); + OptionalCursor(query.CursorSequence); + OptionalIdentifier(query.Lane, nameof(query.Lane), limits); + if (query.Type is not null) Type(query.Type, nameof(query.Type), limits); + } + + internal static void LogQuery(GameHistoryLogQuery query, GameHistoryLimits limits) + { + Limit(query.Limit, limits); + if (query.AfterSequence < 0) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The log cursor cannot be negative."); + } + } + + internal static void Fork(GameHistoryForkOptions options, GameHistoryLimits limits) + { + OptionalIdentifier(options.Id, nameof(options.Id), limits); + OptionalIdentifier(options.ParentSessionId, nameof(options.ParentSessionId), limits); + OptionalIdentifier(options.EntryId, nameof(options.EntryId), limits); + ExpectedSequence(options.ExpectedSourceSequence); + if (options.MetadataJson is not null) + { + JsonObject(options.MetadataJson, nameof(options.MetadataJson), limits.MaxPayloadCharacters); + } + + if (!Enum.IsDefined(typeof(GameHistoryForkScope), options.Scope) + || !Enum.IsDefined(typeof(GameHistoryForkPosition), options.Position)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidInput, "The fork options are invalid."); + } + } + + internal static void Search(GameHistorySearchQuery query, GameHistoryLimits limits) + { + if (query is null) throw new ArgumentNullException(nameof(query)); + if (string.IsNullOrWhiteSpace(query.Text) || query.Text.Length > limits.MaxSearchCharacters || query.Text.IndexOf('\0') >= 0) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The search text is invalid."); + } + + OptionalIdentifier(query.SessionId, nameof(query.SessionId), limits); + if (query.EntryType is not null) Type(query.EntryType, nameof(query.EntryType), limits); + var limit = query.Limit ?? Math.Min(limits.DefaultQueryResults, limits.MaxSearchResults); + if (limit < 1 || limit > limits.MaxSearchResults) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The search limit is invalid."); + } + + if (query.Cursor is not null) + { + SessionId(query.Cursor.SessionId, nameof(query.Cursor.SessionId), limits); + if (query.Cursor.EntrySequence < 1) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The search cursor is invalid."); + } + + if (query.SessionId is not null + && !string.Equals(query.SessionId, query.Cursor.SessionId, StringComparison.Ordinal)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The search cursor belongs to another session."); + } + } + } + + private static void OptionalCursor(long? cursor) + { + if (cursor is < 0) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The query cursor cannot be negative."); + } + } + + private static void Order(GameHistoryOrder order) + { + if (!Enum.IsDefined(typeof(GameHistoryOrder), order)) + { + throw new GameHistoryException(GameHistoryErrorCode.InvalidQuery, "The query order is invalid."); + } + } +} + +internal static class GameHistoryObjectValidation +{ + internal static string Required(string value, string name) + { + if (string.IsNullOrWhiteSpace(value) || value.Any(char.IsControl)) + { + throw new ArgumentException("A non-empty value without control characters is required.", name); + } + + return value; + } + + internal static string? Optional(string? value, string name) => value is null ? null : Required(value, name); + + internal static long Sequence(long value, string name) + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(name); + } + + return value; + } + + internal static string Json(string value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + return value; + } + + internal static string JsonObject(string value, string name) + { + Json(value, name); + using var document = JsonDocument.Parse(value); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("A JSON object is required.", name); + } + + return value; + } +} diff --git a/src/OpenGameAgent/Generation.cs b/src/OpenGameAgent/Generation.cs index 12fc748..e5e2b5c 100644 --- a/src/OpenGameAgent/Generation.cs +++ b/src/OpenGameAgent/Generation.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -60,7 +61,11 @@ public GameMediaGenerationRequest( public sealed class GameMediaGenerationProgress { - public GameMediaGenerationProgress(string stage, double? fraction = null, string? detailsJson = null) + public GameMediaGenerationProgress( + string stage, + double? fraction = null, + string? detailsJson = null, + ResourceContent? preview = null) { if (fraction is < 0 or > 1 || double.IsNaN(fraction ?? 0) || double.IsInfinity(fraction ?? 0)) { @@ -70,6 +75,7 @@ public GameMediaGenerationProgress(string stage, double? fraction = null, string Stage = GameJson.RequireId(stage, nameof(stage)); Fraction = fraction; DetailsJson = detailsJson is null ? null : GameJson.RequireValid(detailsJson, nameof(detailsJson)); + Preview = preview; } public string Stage { get; } @@ -77,6 +83,8 @@ public GameMediaGenerationProgress(string stage, double? fraction = null, string public double? Fraction { get; } public string? DetailsJson { get; } + + public ResourceContent? Preview { get; } } public sealed class GameMediaGenerationResult @@ -169,12 +177,20 @@ public static AgentTool Create( } await execution.ReportProgressAsync( - new ToolProgress(progress.Stage, progress.Fraction, progress.DetailsJson), + new ToolProgress( + progress.Stage, + progress.Fraction, + progress.DetailsJson, + progress.Preview is null + ? null + : new[] { ToAgentContent(progress.Preview, request.Kind) }), token).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The media generator returned null."); - var content = result.Outputs.Cast().ToList(); + var content = result.Outputs + .Select(output => ToAgentContent(output, request.Kind)) + .ToList(); content.Add(new JsonContent(JsonSerializer.Serialize(new { requestId = request.RequestId, @@ -187,4 +203,59 @@ await execution.ReportProgressAsync( ToolExecutionMode.SafeParallel, conflictKey: _ => input.ActorId + ":media"); } + + private static AgentContent ToAgentContent(ResourceContent resource, GameMediaKind kind) + { + if (resource is null) + { + throw new ArgumentNullException(nameof(resource)); + } + + var expectedPrefix = kind switch + { + GameMediaKind.Image => "image/", + GameMediaKind.Audio => "audio/", + GameMediaKind.Video => "video/", + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + if (!resource.MediaType.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The media generator returned content of the wrong media kind."); + } + + var prefix = "data:" + resource.MediaType + ";base64,"; + if (!resource.Uri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return resource; + } + + var data = resource.Uri.Substring(prefix.Length); + if (data.Length == 0 || data.Any(char.IsWhiteSpace)) + { + throw new InvalidDataException("The media generator returned invalid inline base64 content."); + } + + var maximumBytes = checked((data.Length / 4 + 1) * 3); + var rented = ArrayPool.Shared.Rent(maximumBytes); + try + { + if (!Convert.TryFromBase64String(data, rented, out _)) + { + throw new InvalidDataException("The media generator returned invalid inline base64 content."); + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + + var mediaKind = kind switch + { + GameMediaKind.Image => AgentMediaKind.Image, + GameMediaKind.Audio => AgentMediaKind.Audio, + GameMediaKind.Video => AgentMediaKind.Video, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + return new BinaryContent(mediaKind, data, resource.MediaType, resource.Name); + } } diff --git a/src/OpenGameAgent/Sessions.cs b/src/OpenGameAgent/Sessions.cs index 42c5544..07031c1 100644 --- a/src/OpenGameAgent/Sessions.cs +++ b/src/OpenGameAgent/Sessions.cs @@ -52,6 +52,622 @@ internal GameSessionKey EnsureValid(string parameterName) } } +public enum GameSessionUsageCause +{ + Assistant = 0, + Tool = 1, + Compaction = 2, + BranchSummary = 3, + DeferredFetch = 4, + Hook = 5, + Adjustment = 6, +} + +public sealed class GameSessionUsageRecord +{ + public GameSessionUsageRecord( + string recordId, + GameSessionUsageCause cause, + ModelUsage usage, + string? runId = null, + string? inputId = null, + string? detailsJson = null) + { + if (!Enum.IsDefined(typeof(GameSessionUsageCause), cause)) + { + throw new ArgumentOutOfRangeException(nameof(cause)); + } + + RecordId = GameJson.RequireId(recordId, nameof(recordId)); + Cause = cause; + Usage = usage ?? throw new ArgumentNullException(nameof(usage)); + RunId = runId is null ? null : GameJson.RequireId(runId, nameof(runId)); + InputId = inputId is null ? null : GameJson.RequireId(inputId, nameof(inputId)); + DetailsJson = detailsJson is null ? null : GameJson.RequireValid(detailsJson, nameof(detailsJson)); + } + + public string RecordId { get; } + + public GameSessionUsageCause Cause { get; } + + public ModelUsage Usage { get; } + + public string? RunId { get; } + + public string? InputId { get; } + + public string? DetailsJson { get; } + + internal static bool ValueEquals(GameSessionUsageRecord left, GameSessionUsageRecord right) => + string.Equals(left.RecordId, right.RecordId, StringComparison.Ordinal) + && left.Cause == right.Cause + && string.Equals(left.RunId, right.RunId, StringComparison.Ordinal) + && string.Equals(left.InputId, right.InputId, StringComparison.Ordinal) + && string.Equals(left.DetailsJson, right.DetailsJson, StringComparison.Ordinal) + && UsageEquals(left.Usage, right.Usage); + + private static bool UsageEquals(ModelUsage left, ModelUsage right) => + left.InputTokens == right.InputTokens + && left.OutputTokens == right.OutputTokens + && left.CacheReadTokens == right.CacheReadTokens + && left.CacheWriteTokens == right.CacheWriteTokens + && left.ReasoningTokens == right.ReasoningTokens + && left.CacheWriteOneHourTokens == right.CacheWriteOneHourTokens + && left.Cost.Input.Equals(right.Cost.Input) + && left.Cost.Output.Equals(right.Cost.Output) + && left.Cost.CacheRead.Equals(right.Cost.CacheRead) + && left.Cost.CacheWrite.Equals(right.Cost.CacheWrite); +} + +public sealed class GameSessionUsageTotals +{ + private static readonly GameSessionUsageTotals EmptyValue = new( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0); + + public GameSessionUsageTotals( + long inputTokens, + long outputTokens, + long cacheReadTokens, + long cacheWriteTokens, + long reasoningTokens, + long cacheWriteOneHourTokens, + double inputCost, + double outputCost, + double cacheReadCost, + double cacheWriteCost) + { + if (inputTokens < 0 + || outputTokens < 0 + || cacheReadTokens < 0 + || cacheWriteTokens < 0 + || reasoningTokens < 0 + || reasoningTokens > outputTokens + || cacheWriteOneHourTokens < 0 + || cacheWriteOneHourTokens > cacheWriteTokens) + { + throw new ArgumentOutOfRangeException(nameof(inputTokens), "Cumulative token counts are invalid."); + } + + RequireCost(inputCost, nameof(inputCost)); + RequireCost(outputCost, nameof(outputCost)); + RequireCost(cacheReadCost, nameof(cacheReadCost)); + RequireCost(cacheWriteCost, nameof(cacheWriteCost)); + _ = checked(inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens); + InputTokens = inputTokens; + OutputTokens = outputTokens; + CacheReadTokens = cacheReadTokens; + CacheWriteTokens = cacheWriteTokens; + ReasoningTokens = reasoningTokens; + CacheWriteOneHourTokens = cacheWriteOneHourTokens; + InputCost = inputCost; + OutputCost = outputCost; + CacheReadCost = cacheReadCost; + CacheWriteCost = cacheWriteCost; + } + + public long InputTokens { get; } + + public long OutputTokens { get; } + + public long CacheReadTokens { get; } + + public long CacheWriteTokens { get; } + + public long ReasoningTokens { get; } + + public long CacheWriteOneHourTokens { get; } + + public double InputCost { get; } + + public double OutputCost { get; } + + public double CacheReadCost { get; } + + public double CacheWriteCost { get; } + + public long TotalTokens => checked(InputTokens + OutputTokens + CacheReadTokens + CacheWriteTokens); + + public double CostTotal => InputCost + OutputCost + CacheReadCost + CacheWriteCost; + + internal static GameSessionUsageTotals Empty => EmptyValue; + + internal static GameSessionUsageTotals Add(GameSessionUsageTotals left, GameSessionUsageTotals right) => new( + checked(left.InputTokens + right.InputTokens), + checked(left.OutputTokens + right.OutputTokens), + checked(left.CacheReadTokens + right.CacheReadTokens), + checked(left.CacheWriteTokens + right.CacheWriteTokens), + checked(left.ReasoningTokens + right.ReasoningTokens), + checked(left.CacheWriteOneHourTokens + right.CacheWriteOneHourTokens), + AddCost(left.InputCost, right.InputCost), + AddCost(left.OutputCost, right.OutputCost), + AddCost(left.CacheReadCost, right.CacheReadCost), + AddCost(left.CacheWriteCost, right.CacheWriteCost)); + + internal static bool AtLeast(GameSessionUsageTotals candidate, GameSessionUsageTotals previous) => + candidate.InputTokens >= previous.InputTokens + && candidate.OutputTokens >= previous.OutputTokens + && candidate.CacheReadTokens >= previous.CacheReadTokens + && candidate.CacheWriteTokens >= previous.CacheWriteTokens + && candidate.ReasoningTokens >= previous.ReasoningTokens + && candidate.CacheWriteOneHourTokens >= previous.CacheWriteOneHourTokens + && candidate.InputCost >= previous.InputCost + && candidate.OutputCost >= previous.OutputCost + && candidate.CacheReadCost >= previous.CacheReadCost + && candidate.CacheWriteCost >= previous.CacheWriteCost; + + internal static bool ValueEquals(GameSessionUsageTotals left, GameSessionUsageTotals right) => + left.InputTokens == right.InputTokens + && left.OutputTokens == right.OutputTokens + && left.CacheReadTokens == right.CacheReadTokens + && left.CacheWriteTokens == right.CacheWriteTokens + && left.ReasoningTokens == right.ReasoningTokens + && left.CacheWriteOneHourTokens == right.CacheWriteOneHourTokens + && left.InputCost.Equals(right.InputCost) + && left.OutputCost.Equals(right.OutputCost) + && left.CacheReadCost.Equals(right.CacheReadCost) + && left.CacheWriteCost.Equals(right.CacheWriteCost); + + internal static GameSessionUsageTotals Aggregate(IEnumerable records) + { + var inputTokens = 0L; + var outputTokens = 0L; + var cacheReadTokens = 0L; + var cacheWriteTokens = 0L; + var reasoningTokens = 0L; + var cacheWriteOneHourTokens = 0L; + var inputCost = 0d; + var outputCost = 0d; + var cacheReadCost = 0d; + var cacheWriteCost = 0d; + foreach (var record in records) + { + var usage = record.Usage; + inputTokens = checked(inputTokens + usage.InputTokens); + outputTokens = checked(outputTokens + usage.OutputTokens); + cacheReadTokens = checked(cacheReadTokens + usage.CacheReadTokens); + cacheWriteTokens = checked(cacheWriteTokens + usage.CacheWriteTokens); + reasoningTokens = checked(reasoningTokens + (usage.ReasoningTokens ?? 0)); + cacheWriteOneHourTokens = checked(cacheWriteOneHourTokens + (usage.CacheWriteOneHourTokens ?? 0)); + inputCost = AddCost(inputCost, usage.Cost.Input); + outputCost = AddCost(outputCost, usage.Cost.Output); + cacheReadCost = AddCost(cacheReadCost, usage.Cost.CacheRead); + cacheWriteCost = AddCost(cacheWriteCost, usage.Cost.CacheWrite); + } + + return new GameSessionUsageTotals( + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens, + cacheWriteOneHourTokens, + inputCost, + outputCost, + cacheReadCost, + cacheWriteCost); + } + + private static double AddCost(double left, double right) + { + var result = left + right; + return double.IsNaN(result) || double.IsInfinity(result) + ? throw new OverflowException("The cumulative model cost is too large.") + : result; + } + + private static void RequireCost(double value, string name) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0) + { + throw new ArgumentOutOfRangeException(name, "Cumulative costs must be finite and non-negative."); + } + } +} + +public sealed class GameSessionUsageStats +{ + private readonly IReadOnlyDictionary _byCause; + + internal GameSessionUsageStats( + IReadOnlyDictionary totalsByCause) + { + Total = totalsByCause.Values.Aggregate( + GameSessionUsageTotals.Empty, + GameSessionUsageTotals.Add); + _byCause = new ReadOnlyDictionary( + new Dictionary(totalsByCause)); + } + + public GameSessionUsageTotals Total { get; } + + public long CachedTokens => Total.CacheReadTokens; + + public long UncachedTokens => checked(Total.InputTokens + Total.CacheWriteTokens); + + public long TotalTokens => Total.TotalTokens; + + public double CostTotal => Total.CostTotal; + + public GameSessionUsageTotals ForCause(GameSessionUsageCause cause) + { + if (!Enum.IsDefined(typeof(GameSessionUsageCause), cause)) + { + throw new ArgumentOutOfRangeException(nameof(cause)); + } + + return _byCause.TryGetValue(cause, out var totals) ? totals : GameSessionUsageTotals.Empty; + } +} + +public sealed class GameSessionUsageLedger +{ + public const int DefaultRecentRecordCapacity = 256; + private const int MaximumRecentRecordCapacity = 16_384; + private readonly IReadOnlyDictionary _byId; + private readonly IReadOnlyDictionary _totalsByCause; + + public GameSessionUsageLedger( + IEnumerable? records = null, + int recentRecordCapacity = DefaultRecentRecordCapacity) + : this(PrepareInitial(records, recentRecordCapacity), recentRecordCapacity) + { + } + + private GameSessionUsageLedger(PreparedLedger prepared, int recentRecordCapacity) + : this( + prepared.RecentRecords, + prepared.TotalsByCause, + prepared.TotalRecordCount, + recentRecordCapacity) + { + } + + private GameSessionUsageLedger( + IReadOnlyList recentRecords, + IReadOnlyDictionary? totalsByCause, + long? totalRecordCount, + int recentRecordCapacity) + { + ValidateCapacity(recentRecordCapacity); + var copied = (recentRecords ?? throw new ArgumentNullException(nameof(recentRecords))).ToArray(); + if (copied.Any(record => record is null)) + { + throw new ArgumentException("A usage ledger cannot contain null records.", nameof(recentRecords)); + } + + if (copied.Length > recentRecordCapacity) + { + throw new ArgumentException("The recent usage record window exceeds its configured capacity.", nameof(recentRecords)); + } + + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var record in copied) + { + if (!byId.TryAdd(record.RecordId, record)) + { + throw new ArgumentException($"Duplicate usage record ID '{record.RecordId}'.", nameof(recentRecords)); + } + } + + var recentTotals = AggregateByCause(copied); + var cumulative = totalsByCause is null + ? recentTotals + : CopyTotals(totalsByCause); + foreach (var pair in recentTotals) + { + if (!cumulative.TryGetValue(pair.Key, out var total) + || !GameSessionUsageTotals.AtLeast(total, pair.Value)) + { + throw new ArgumentException( + $"Cumulative usage for '{pair.Key}' cannot be smaller than its retained records.", + nameof(totalsByCause)); + } + } + + var count = totalRecordCount ?? copied.LongLength; + if (count < copied.LongLength) + { + throw new ArgumentOutOfRangeException(nameof(totalRecordCount)); + } + + Records = Array.AsReadOnly(copied); + _byId = new ReadOnlyDictionary(byId); + _totalsByCause = new ReadOnlyDictionary(cumulative); + RecentRecordCapacity = recentRecordCapacity; + TotalRecordCount = count; + Stats = new GameSessionUsageStats(_totalsByCause); + } + + /// + /// Bounded recent records used for idempotent CAS replay and near-term audit. Historical usage is + /// folded into cumulative per-cause totals, so snapshot size does not grow with session lifetime. + /// + public IReadOnlyList Records { get; } + + public int RecentRecordCapacity { get; } + + public long TotalRecordCount { get; } + + public IReadOnlyDictionary TotalsByCause => _totalsByCause; + + public GameSessionUsageStats Stats { get; } + + public static GameSessionUsageLedger Restore( + IEnumerable? recentRecords, + IReadOnlyDictionary totalsByCause, + long totalRecordCount, + int recentRecordCapacity = DefaultRecentRecordCapacity) => new( + (recentRecords ?? Array.Empty()).ToArray(), + totalsByCause ?? throw new ArgumentNullException(nameof(totalsByCause)), + totalRecordCount, + recentRecordCapacity); + + public GameSessionUsageLedger Append(IEnumerable records) + { + if (records is null) + { + throw new ArgumentNullException(nameof(records)); + } + + var combined = Records.ToList(); + var byId = Records.ToDictionary(record => record.RecordId, StringComparer.Ordinal); + var totals = CopyTotals(_totalsByCause); + var totalRecordCount = TotalRecordCount; + foreach (var record in records) + { + if (record is null) + { + throw new ArgumentException("Usage record collections cannot contain null values.", nameof(records)); + } + + if (byId.TryGetValue(record.RecordId, out var existing)) + { + if (!GameSessionUsageRecord.ValueEquals(existing, record)) + { + throw new InvalidOperationException( + $"Usage record '{record.RecordId}' was replayed with different content."); + } + + continue; + } + + byId.Add(record.RecordId, record); + combined.Add(record); + var added = GameSessionUsageTotals.Aggregate(new[] { record }); + totals[record.Cause] = totals.TryGetValue(record.Cause, out var current) + ? GameSessionUsageTotals.Add(current, added) + : added; + totalRecordCount = checked(totalRecordCount + 1); + } + + if (totalRecordCount == TotalRecordCount) + { + return this; + } + + if (combined.Count > RecentRecordCapacity) + { + combined.RemoveRange(0, combined.Count - RecentRecordCapacity); + } + + return new GameSessionUsageLedger( + combined, + totals, + totalRecordCount, + RecentRecordCapacity); + } + + public void EnsureExtends(GameSessionUsageLedger previous) + { + if (previous is null) + { + throw new ArgumentNullException(nameof(previous)); + } + + if (RecentRecordCapacity != previous.RecentRecordCapacity) + { + throw new ArgumentException("A saved session cannot change its usage replay-window capacity.", nameof(previous)); + } + + if (TotalRecordCount < previous.TotalRecordCount) + { + throw new ArgumentException("A saved session cannot reduce its cumulative usage record count.", nameof(previous)); + } + + if (TotalRecordCount == previous.TotalRecordCount + && (Records.Count != previous.Records.Count + || !Records.Zip(previous.Records, GameSessionUsageRecord.ValueEquals).All(equal => equal))) + { + throw new ArgumentException( + "A saved session cannot change recent usage records without appending usage.", + nameof(previous)); + } + + foreach (GameSessionUsageCause cause in Enum.GetValues(typeof(GameSessionUsageCause))) + { + var candidate = _totalsByCause.TryGetValue(cause, out var candidateValue) + ? candidateValue + : GameSessionUsageTotals.Empty; + var prior = previous._totalsByCause.TryGetValue(cause, out var priorValue) + ? priorValue + : GameSessionUsageTotals.Empty; + if (!GameSessionUsageTotals.AtLeast(candidate, prior)) + { + throw new ArgumentException( + $"A saved session cannot reduce cumulative usage for '{cause}'.", + nameof(previous)); + } + + if (TotalRecordCount == previous.TotalRecordCount + && !GameSessionUsageTotals.ValueEquals(candidate, prior)) + { + throw new ArgumentException( + $"A saved session cannot change cumulative usage for '{cause}' without appending usage.", + nameof(previous)); + } + } + + foreach (var record in previous.Records) + { + if (_byId.TryGetValue(record.RecordId, out var retained) + && !GameSessionUsageRecord.ValueEquals(record, retained)) + { + throw new ArgumentException( + $"A saved session cannot rewrite usage record '{record.RecordId}'.", + nameof(previous)); + } + } + + var retainedPrevious = previous.Records.Where(record => _byId.ContainsKey(record.RecordId)).ToArray(); + if (retainedPrevious.Length > 0) + { + var expectedSuffix = previous.Records.Skip(previous.Records.Count - retainedPrevious.Length); + if (!expectedSuffix.Zip(retainedPrevious, GameSessionUsageRecord.ValueEquals).All(equal => equal) + || !Records.Take(retainedPrevious.Length) + .Zip(retainedPrevious, GameSessionUsageRecord.ValueEquals) + .All(equal => equal)) + { + throw new ArgumentException("Recent usage records must advance as an append-only window.", nameof(previous)); + } + } + else if (previous.Records.Count > 0 + && Records.Count < RecentRecordCapacity + && TotalRecordCount > previous.TotalRecordCount) + { + throw new ArgumentException("Recent usage records were removed before the replay window filled.", nameof(previous)); + } + + var visibleNewRecords = Records.Skip(retainedPrevious.Length).ToArray(); + if (TotalRecordCount - previous.TotalRecordCount == visibleNewRecords.LongLength) + { + var expectedTotals = CopyTotals(previous._totalsByCause); + foreach (var pair in AggregateByCause(visibleNewRecords)) + { + expectedTotals[pair.Key] = expectedTotals.TryGetValue(pair.Key, out var total) + ? GameSessionUsageTotals.Add(total, pair.Value) + : pair.Value; + } + + if (!TotalsEqual(expectedTotals, _totalsByCause)) + { + throw new ArgumentException( + "Cumulative usage totals do not match the appended usage records.", + nameof(previous)); + } + } + } + + private static PreparedLedger PrepareInitial( + IEnumerable? records, + int recentRecordCapacity) + { + ValidateCapacity(recentRecordCapacity); + var copied = (records ?? Array.Empty()).ToArray(); + if (copied.Any(record => record is null)) + { + throw new ArgumentException("A usage ledger cannot contain null records.", nameof(records)); + } + + var duplicate = copied + .GroupBy(record => record.RecordId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate usage record ID '{duplicate.Key}'.", nameof(records)); + } + + var recent = copied.Length <= recentRecordCapacity + ? copied + : copied.Skip(copied.Length - recentRecordCapacity).ToArray(); + return new PreparedLedger(recent, AggregateByCause(copied), copied.LongLength); + } + + private static Dictionary AggregateByCause( + IEnumerable records) => records + .GroupBy(record => record.Cause) + .ToDictionary(group => group.Key, group => GameSessionUsageTotals.Aggregate(group)); + + private static Dictionary CopyTotals( + IReadOnlyDictionary source) + { + var copy = new Dictionary(); + foreach (var pair in source) + { + if (!Enum.IsDefined(typeof(GameSessionUsageCause), pair.Key) || pair.Value is null) + { + throw new ArgumentException("Cumulative usage contains an invalid cause or total.", nameof(source)); + } + + copy.Add(pair.Key, pair.Value); + } + + return copy; + } + + private static bool TotalsEqual( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var total) + && GameSessionUsageTotals.ValueEquals(pair.Value, total)); + + private static void ValidateCapacity(int recentRecordCapacity) + { + if (recentRecordCapacity is < 1 or > MaximumRecentRecordCapacity) + { + throw new ArgumentOutOfRangeException(nameof(recentRecordCapacity)); + } + } + + private sealed class PreparedLedger + { + public PreparedLedger( + IReadOnlyList recentRecords, + IReadOnlyDictionary totalsByCause, + long totalRecordCount) + { + RecentRecords = recentRecords; + TotalsByCause = totalsByCause; + TotalRecordCount = totalRecordCount; + } + + public IReadOnlyList RecentRecords { get; } + + public IReadOnlyDictionary TotalsByCause { get; } + + public long TotalRecordCount { get; } + } +} + public sealed class GameSessionSnapshot { public GameSessionSnapshot( @@ -61,7 +677,8 @@ public GameSessionSnapshot( IReadOnlyCollection? processedInputIds = null, GameMoment? lastMoment = null, IReadOnlyDictionary? extensionState = null, - string? pendingInputId = null) + string? pendingInputId = null, + GameSessionUsageLedger? usageLedger = null) { if (revision < 0) { @@ -103,6 +720,7 @@ public GameSessionSnapshot( } ExtensionState = new ReadOnlyDictionary(copiedExtensionState); + UsageLedger = usageLedger ?? new GameSessionUsageLedger(); } public GameSessionKey Key { get; } @@ -126,6 +744,8 @@ public GameSessionSnapshot( /// Namespaced extension-owned JSON state. It is persisted but never added to model context automatically. /// public IReadOnlyDictionary ExtensionState { get; } + + public GameSessionUsageLedger UsageLedger { get; } } public sealed class GameSessionSaveResult @@ -199,6 +819,8 @@ public ValueTask SaveAsync( return new ValueTask( new GameSessionSaveResult(saved: false, Copy(current))); } + + snapshot.UsageLedger.EnsureExtends(current.UsageLedger); } else { @@ -235,5 +857,6 @@ private static GameSessionSnapshot Copy(GameSessionSnapshot snapshot) => snapshot.ProcessedInputIds, snapshot.LastMoment, snapshot.ExtensionState, - snapshot.PendingInputId); + snapshot.PendingInputId, + snapshot.UsageLedger); } diff --git a/src/OpenGameAgent/Skills.cs b/src/OpenGameAgent/Skills.cs index d46bd7f..05fae84 100644 --- a/src/OpenGameAgent/Skills.cs +++ b/src/OpenGameAgent/Skills.cs @@ -9,6 +9,29 @@ namespace OpenGameAgent; public sealed class GameSkill { + public GameSkill( + string skillId, + string name, + string description, + string instructions, + IReadOnlyCollection? inputTypes, + IReadOnlyCollection? toolNames, + int priority, + IReadOnlyDictionary? metadata) + : this( + skillId, + name, + description, + instructions, + inputTypes, + toolNames, + priority, + metadata, + disableModelInvocation: false, + sourceInfo: null) + { + } + public GameSkill( string skillId, string name, @@ -17,7 +40,9 @@ public GameSkill( IReadOnlyCollection? inputTypes = null, IReadOnlyCollection? toolNames = null, int priority = 0, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + bool disableModelInvocation = false, + GameResourceSourceInfo? sourceInfo = null) { SkillId = GameJson.RequireId(skillId, nameof(skillId)); Name = GameJson.RequireId(name, nameof(name)); @@ -41,6 +66,8 @@ public GameSkill( } Metadata = new ReadOnlyDictionary(copiedMetadata); + DisableModelInvocation = disableModelInvocation; + SourceInfo = sourceInfo; } public string SkillId { get; } @@ -58,6 +85,10 @@ public GameSkill( public int Priority { get; } public IReadOnlyDictionary Metadata { get; } + + public bool DisableModelInvocation { get; } + + public GameResourceSourceInfo? SourceInfo { get; } } public sealed class GameSkillQuery @@ -133,6 +164,7 @@ public ValueTask> SelectAsync( var tools = new HashSet(query.AvailableTools, StringComparer.Ordinal); var selected = _skills + .Where(skill => !skill.DisableModelInvocation) .Where(skill => skill.InputTypes.Count == 0 || skill.InputTypes.Contains(query.Input.Type, StringComparer.Ordinal)) .Where(skill => skill.ToolNames.All(tools.Contains)) .OrderByDescending(skill => skill.Priority) diff --git a/src/OpenGameAgent/Transcripts.cs b/src/OpenGameAgent/Transcripts.cs index 3161cbc..c9ecd68 100644 --- a/src/OpenGameAgent/Transcripts.cs +++ b/src/OpenGameAgent/Transcripts.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using OpenGameAgent.Kernel; @@ -14,7 +17,8 @@ public GameTranscriptCompactionContext( IReadOnlyList messages, int targetMessageCount, long? targetEstimatedTokens = null, - GameTranscriptTokenEstimator? tokenEstimator = null) + GameTranscriptTokenEstimator? tokenEstimator = null, + long? maximumSummaryUsageTokens = null) { if (targetMessageCount < 1) { @@ -33,6 +37,11 @@ public GameTranscriptCompactionContext( nameof(tokenEstimator)); } + if (maximumSummaryUsageTokens is < 0) + { + throw new ArgumentOutOfRangeException(nameof(maximumSummaryUsageTokens)); + } + Session = session.EnsureValid(nameof(session)); var copiedMessages = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); if (copiedMessages.Any(message => message is null)) @@ -44,6 +53,7 @@ public GameTranscriptCompactionContext( TargetMessageCount = targetMessageCount; TargetEstimatedTokens = targetEstimatedTokens; TokenEstimator = tokenEstimator; + MaximumSummaryUsageTokens = maximumSummaryUsageTokens; } public GameSessionKey Session { get; } @@ -55,6 +65,8 @@ public GameTranscriptCompactionContext( public long? TargetEstimatedTokens { get; } public GameTranscriptTokenEstimator? TokenEstimator { get; } + + public long? MaximumSummaryUsageTokens { get; } } public delegate long GameTranscriptTokenEstimator(IReadOnlyList messages); @@ -180,155 +192,1652 @@ private static long DivideRoundUp(long value, long divisor) => checked((value + divisor - 1) / divisor); } -public interface IGameTranscriptCompactor +internal sealed class GameModelRecoverySafety { - ValueTask> CompactAsync( - GameTranscriptCompactionContext context, - CancellationToken cancellationToken); + private int _toolSideEffectObserved; + private int _recoveryStarted; + + public GameModelRecoverySafety(bool toolSideEffectObserved) + { + _toolSideEffectObserved = toolSideEffectObserved ? 1 : 0; + } + + public bool CanReplay => Volatile.Read(ref _toolSideEffectObserved) == 0; + + public bool TryBeginRecovery() => + CanReplay + && Volatile.Read(ref _recoveryStarted) == 0 + && Interlocked.CompareExchange(ref _recoveryStarted, 1, 0) == 0; + + public void Record(AgentEvent agentEvent) + { + if (agentEvent is null) + { + throw new ArgumentNullException(nameof(agentEvent)); + } + + if (agentEvent.Kind is AgentEventKind.ToolStarted + or AgentEventKind.ToolProgressed + or AgentEventKind.ToolEnded) + { + Interlocked.Exchange(ref _toolSideEffectObserved, 1); + } + } } -public delegate ValueTask GameTranscriptSummarizer( - GameSessionKey session, - IReadOnlyList messages, - CancellationToken cancellationToken); +internal sealed class GameModelRecoveryCompaction +{ + public GameModelRecoveryCompaction( + ModelRequest request, + GameTranscriptCompactionResult compaction) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + Compaction = compaction ?? throw new ArgumentNullException(nameof(compaction)); + } -public sealed class SummarizingGameTranscriptCompactor : IGameTranscriptCompactor + public ModelRequest Request { get; } + + public GameTranscriptCompactionResult Compaction { get; } +} + +internal sealed class ContextOverflowRecoveryModelProvider : IModelProvider { - private readonly GameTranscriptSummarizer _summarizer; + private readonly IModelProvider _inner; + private readonly GameModelRecoverySafety _safety; + private readonly int _contextWindowTokens; + private readonly Func> _compact; + private readonly Action _recordFailedAttemptAndSuppress; + private readonly Action _recordCompaction; + private readonly Action _recordCompactionFailure; + private readonly Action _clearAssistantSuppression; - public SummarizingGameTranscriptCompactor(GameTranscriptSummarizer summarizer) + public ContextOverflowRecoveryModelProvider( + IModelProvider inner, + GameModelRecoverySafety safety, + int contextWindowTokens, + Func> compact, + Action recordFailedAttemptAndSuppress, + Action recordCompaction, + Action recordCompactionFailure, + Action clearAssistantSuppression) { - _summarizer = summarizer ?? throw new ArgumentNullException(nameof(summarizer)); + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _safety = safety ?? throw new ArgumentNullException(nameof(safety)); + _contextWindowTokens = contextWindowTokens > 0 + ? contextWindowTokens + : throw new ArgumentOutOfRangeException(nameof(contextWindowTokens)); + _compact = compact ?? throw new ArgumentNullException(nameof(compact)); + _recordFailedAttemptAndSuppress = recordFailedAttemptAndSuppress + ?? throw new ArgumentNullException(nameof(recordFailedAttemptAndSuppress)); + _recordCompaction = recordCompaction ?? throw new ArgumentNullException(nameof(recordCompaction)); + _recordCompactionFailure = recordCompactionFailure ?? throw new ArgumentNullException(nameof(recordCompactionFailure)); + _clearAssistantSuppression = clearAssistantSuppression + ?? throw new ArgumentNullException(nameof(clearAssistantSuppression)); } - public async ValueTask> CompactAsync( - GameTranscriptCompactionContext context, - CancellationToken cancellationToken) + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) { - if (context is null) + if (request is null) { - throw new ArgumentNullException(nameof(context)); + throw new ArgumentNullException(nameof(request)); } - if (Fits(context, context.Messages)) + var activeRequest = request; + for (var attempt = 0; attempt < 2; attempt++) { - return context.Messages; + var enumerator = _inner.StreamAsync(activeRequest, cancellationToken).GetAsyncEnumerator(cancellationToken); + ModelStreamEvent? pendingStart = null; + ModelResponse? recoveryResponse = null; + Exception? recoveryException = null; + Exception? primaryFailure = null; + var meaningfulEventExposed = false; + var terminalSeen = false; + try + { + while (true) + { + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + primaryFailure = exception; + if (GameModelContextOverflowClassifier.IsContextOverflow(exception) + && TryBeginRecovery(attempt, meaningfulEventExposed)) + { + recoveryException = exception; + break; + } + + throw; + } + + if (!hasNext) + { + if (pendingStart is not null) + { + yield return pendingStart; + } + + yield break; + } + + var current = enumerator.Current + ?? throw new InvalidOperationException("The model provider emitted a null stream event."); + if (current.Kind == ModelStreamEventKind.Started) + { + if (pendingStart is not null) + { + throw new InvalidOperationException("The model provider emitted more than one stream start event."); + } + + pendingStart = current; + if (HasMeaningfulStartedContent(current)) + { + meaningfulEventExposed = true; + yield return pendingStart; + pendingStart = null; + } + + continue; + } + + if (current.IsTerminal) + { + terminalSeen = true; + var response = current.Response + ?? throw new InvalidOperationException("A terminal model event did not contain a response."); + if (GameModelContextOverflowClassifier.IsContextOverflow(response, _contextWindowTokens) + && TryBeginRecovery(attempt, meaningfulEventExposed)) + { + recoveryResponse = response; + break; + } + + if (pendingStart is not null) + { + yield return pendingStart; + } + + yield return current; + yield break; + } + + meaningfulEventExposed = true; + if (pendingStart is not null) + { + yield return pendingStart; + pendingStart = null; + } + + yield return current; + } + } + finally + { + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch when (primaryFailure is not null + || recoveryException is not null + || recoveryResponse is not null + || terminalSeen + || cancellationToken.IsCancellationRequested) + { + // Stream cleanup cannot replace the primary provider outcome. + } + } + + var failedUsage = recoveryResponse?.Usage ?? new ModelUsage(); + if (recoveryResponse is not null) + { + _recordFailedAttemptAndSuppress(failedUsage, activeRequest.RunId, "provider_response"); + } + + GameModelRecoveryCompaction? compacted = null; + GameTranscriptCompactionException? compactionFailure = null; + Exception? unexpectedCompactionFailure = null; + try + { + compacted = await _compact(activeRequest, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (GameTranscriptCompactionException exception) + { + compactionFailure = exception; + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + unexpectedCompactionFailure = exception; + } + + if (compactionFailure is not null) + { + _recordCompactionFailure(compactionFailure); + } + + if (compacted is null || unexpectedCompactionFailure is not null) + { + if (recoveryResponse is not null) + { + if (pendingStart is not null) + { + yield return pendingStart; + } + + yield return ModelStreamEvent.Terminal(recoveryResponse); + yield break; + } + + ExceptionDispatchInfo.Capture(recoveryException!).Throw(); + throw new InvalidOperationException("The provider failure could not be rethrown."); + } + + _recordCompaction(compacted.Compaction); + if (recoveryResponse is not null) + { + _clearAssistantSuppression(activeRequest.RunId); + } + + activeRequest = compacted.Request; } + } - var keepCount = Math.Max(1, context.TargetMessageCount - 1); - var start = FindSafeSuffixStart(context, keepCount); - if (start == 0) + private bool TryBeginRecovery(int attempt, bool meaningfulEventExposed) => + attempt == 0 + && !meaningfulEventExposed + && _safety.TryBeginRecovery(); + + private static bool HasMeaningfulStartedContent(ModelStreamEvent streamEvent) => + streamEvent.Partial is { Content.Count: > 0 }; +} + +internal static class GameModelContextOverflowClassifier +{ + private static readonly string[] ExcludedFragments = + { + "rate limit", + "rate_limit", + "too many requests", + "quota", + "billing", + "insufficient credit", + "insufficient_credit", + "service unavailable", + "overloaded", + }; + + private static readonly string[] OverflowFragments = + { + "maximum context length", + "context length exceeded", + "context_length_exceeded", + "context window exceeded", + "context_window_exceeded", + "model context window exceeded", + "model_context_window_exceeded", + "exceeds the context window", + "exceeded the context window", + "input is too long", + "input_too_long", + "prompt is too long", + "prompt_too_long", + "too many tokens", + "token limit exceeded", + "request too large for model", + "reduce the length of the messages", + "exceeds the maximum number of tokens allowed", + "maximum prompt length", + "maximum allowed input length", + "longer than the model's context length", + "longer than the model context length", + "exceeds the available context size", + "greater than the context length", + "context window exceeds limit", + "exceeded model token limit", + "configured context size", + "range of input length should be", + "400 status code (no body)", + "413 status code (no body)", + }; + + private static readonly HashSet StructuredOverflowCodes = new(StringComparer.OrdinalIgnoreCase) + { + "context_length_exceeded", + "context_window_exceeded", + "model_context_window_exceeded", + "input_too_long", + "prompt_too_long", + "request_too_large", + }; + + public static bool IsContextOverflow(Exception exception) + { + if (exception is null) { - throw new InvalidOperationException("The transcript cannot be compacted without splitting a tool exchange."); + throw new ArgumentNullException(nameof(exception)); } - if (start < 0) + if (exception is OperationCanceledException) { - // No complete conversational suffix fits. Summarizing the entire - // transcript is still safe and leaves a single canonical message. - start = context.Messages.Count; + return false; } - var removed = context.Messages.Take(start).ToArray(); - var summary = await _summarizer(context.Session, removed, cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(summary)) + if (exception is ModelProviderException providerFailure) { - throw new InvalidOperationException("The transcript summarizer returned an empty summary."); - } + if (providerFailure.StatusCode == 429 || ContainsExcluded(providerFailure.Message)) + { + return false; + } + + if (HasStructuredOverflow(providerFailure.Diagnostics)) + { + return true; + } - var summaryMessage = new AgentMessage( - AgentRole.Custom, - new AgentContent[] { new TextContent(summary) }, - DateTimeOffset.UtcNow, - customRole: "transcript_summary", - metadata: new Dictionary + if (providerFailure.StatusCode == 413) { - ["game.compacted_message_count"] = removed.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), - }); - var result = new[] { summaryMessage }.Concat(context.Messages.Skip(start)).ToArray(); - if (result.Length > context.TargetMessageCount) + return true; + } + + if (providerFailure.StatusCode is not null + && providerFailure.StatusCode is not 400 and not 422) + { + return false; + } + } + else if (ContainsExcluded(exception.Message)) + { + return false; + } + + return ContainsOverflow(exception.Message); + } + + public static bool IsContextOverflow(ModelResponse response, int contextWindowTokens) + { + if (response is null) + { + throw new ArgumentNullException(nameof(response)); + } + + if (contextWindowTokens <= 0) + { + throw new ArgumentOutOfRangeException(nameof(contextWindowTokens)); + } + + var diagnosticText = string.Join(" ", response.Diagnostics.Select(diagnostic => diagnostic.Message)); + if (ContainsExcluded(response.ErrorMessage) + || ContainsExcluded(response.RawStopReason) + || ContainsExcluded(diagnosticText)) + { + return false; + } + + if (HasStructuredOverflow(response.Diagnostics) + || IsStructuredOverflowCode(response.RawStopReason)) + { + return response.Content.Count == 0; + } + + if (response.StopReason == ModelStopReason.Error) + { + return response.Content.Count == 0 + && (ContainsOverflow(response.ErrorMessage) || ContainsOverflow(diagnosticText)); + } + + if (response.Content.Count != 0 || response.Usage.OutputTokens != 0) { - throw new InvalidOperationException("The transcript compactor exceeded its requested target."); + return false; } - if (context.TargetEstimatedTokens is { } tokenTarget - && Estimate(context, result) > tokenTarget) + var inputTokens = checked(response.Usage.InputTokens + response.Usage.CacheReadTokens); + if (response.StopReason == ModelStopReason.Length) { - throw new InvalidOperationException("The transcript compactor exceeded its requested token target."); + var threshold = checked((contextWindowTokens * 99L + 99L) / 100L); + return inputTokens >= threshold; } - ValidateToolExchanges(result); - return result; + return response.StopReason == ModelStopReason.Stop && inputTokens > contextWindowTokens; } - private static int FindSafeSuffixStart(GameTranscriptCompactionContext context, int keepCount) + private static bool HasStructuredOverflow(IReadOnlyList diagnostics) { - var messages = context.Messages; - var desired = Math.Max(1, messages.Count - keepCount); - for (var index = desired; index < messages.Count; index++) + foreach (var diagnostic in diagnostics) { - if (messages[index].Role is AgentRole.User or AgentRole.Custom) + if (IsStructuredOverflowCode(diagnostic.Code)) { - var projectedCount = checked(messages.Count - index + 1); - if (projectedCount > context.TargetMessageCount) - { - continue; - } - - if (context.TargetEstimatedTokens is { } tokenTarget) - { - // Leave half of the transcript budget available to the summary. This is - // conservative and the completed summary is checked again below. - var suffixTarget = Math.Max(1, tokenTarget / 2); - if (Estimate(context, messages.Skip(index).ToArray()) > suffixTarget) - { - continue; - } - } + return true; + } - return index; + if (diagnostic.DataJson is { } data && HasStructuredOverflowJson(data)) + { + return true; } } - return -1; + return false; } - private static bool Fits(GameTranscriptCompactionContext context, IReadOnlyList messages) => - messages.Count <= context.TargetMessageCount - && (context.TargetEstimatedTokens is not { } target || Estimate(context, messages) <= target); - - private static long Estimate(GameTranscriptCompactionContext context, IReadOnlyList messages) + private static bool HasStructuredOverflowJson(string json) { - var estimate = context.TokenEstimator!(messages); - return estimate >= 0 - ? estimate - : throw new InvalidOperationException("The transcript token estimator returned a negative value."); + try + { + using var document = JsonDocument.Parse(json); + return HasStructuredOverflowJson(document.RootElement); + } + catch (JsonException) + { + return false; + } } - private static void ValidateToolExchanges(IReadOnlyList messages) + private static bool HasStructuredOverflowJson(JsonElement element) { - var openCalls = new HashSet(StringComparer.Ordinal); - foreach (var message in messages) + if (element.ValueKind == JsonValueKind.Object) { - if (message.Role == AgentRole.Assistant) + foreach (var property in element.EnumerateObject()) { - foreach (var call in message.Content.OfType()) + if (property.Value.ValueKind == JsonValueKind.String + && IsDiagnosticCodeProperty(property.Name) + && IsStructuredOverflowCode(property.Value.GetString())) + { + return true; + } + + if (HasStructuredOverflowJson(property.Value)) { - openCalls.Add(call.Id); + return true; } } - else if (message.Role == AgentRole.Tool && message.ToolCallId is { } callId) + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) { - if (!openCalls.Remove(callId)) + if (HasStructuredOverflowJson(item)) { - throw new InvalidOperationException("The compacted transcript contains an orphan tool result."); + return true; } } } - if (openCalls.Count > 0) + return false; + } + + private static bool IsDiagnosticCodeProperty(string value) => + string.Equals(value, "code", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "errorCode", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "type", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "reason", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "stop_reason", StringComparison.OrdinalIgnoreCase); + + private static bool IsStructuredOverflowCode(string? value) => + value is not null && StructuredOverflowCodes.Contains(value.Trim()); + + private static bool ContainsExcluded(string? value) => ContainsAny(value, ExcludedFragments); + + private static bool ContainsOverflow(string? value) => ContainsAny(value, OverflowFragments); + + private static bool ContainsAny(string? value, IReadOnlyList fragments) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + foreach (var fragment in fragments) + { + if (value.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } +} + +public interface IGameTranscriptCompactor +{ + ValueTask CompactAsync( + GameTranscriptCompactionContext context, + CancellationToken cancellationToken); +} + +public sealed class GameTranscriptSummaryResult +{ + public GameTranscriptSummaryResult( + string summary, + ModelUsage? usage = null, + string? detailsJson = null) + { + Summary = string.IsNullOrWhiteSpace(summary) + ? throw new ArgumentException("A transcript summary is required.", nameof(summary)) + : summary; + Usage = usage ?? new ModelUsage(); + DetailsJson = detailsJson is null ? null : GameJson.RequireValid(detailsJson, nameof(detailsJson)); + } + + public string Summary { get; } + + public ModelUsage Usage { get; } + + public string? DetailsJson { get; } +} + +public enum GameTranscriptSummaryPurpose +{ + Compaction, + Branch, +} + +public sealed class GameTranscriptSummaryContext +{ + internal GameTranscriptSummaryContext( + GameSessionKey session, + IReadOnlyList sourceMessages, + IReadOnlyList messages, + string? previousSummary, + GameTranscriptSummaryPurpose purpose, + int attempt, + string? previousError, + long? targetEstimatedTokens) + { + Session = session.EnsureValid(nameof(session)); + SourceMessages = CopyMessages(sourceMessages, nameof(sourceMessages)); + Messages = CopyMessages(messages, nameof(messages)); + PreviousSummary = string.IsNullOrWhiteSpace(previousSummary) ? null : previousSummary; + if (!Enum.IsDefined(typeof(GameTranscriptSummaryPurpose), purpose)) + { + throw new ArgumentOutOfRangeException(nameof(purpose)); + } + + Purpose = purpose; + Attempt = attempt > 0 ? attempt : throw new ArgumentOutOfRangeException(nameof(attempt)); + PreviousError = string.IsNullOrWhiteSpace(previousError) ? null : previousError; + TargetEstimatedTokens = targetEstimatedTokens is > 0 + ? targetEstimatedTokens + : targetEstimatedTokens is null + ? null + : throw new ArgumentOutOfRangeException(nameof(targetEstimatedTokens)); + } + + public GameSessionKey Session { get; } + + /// + /// The complete source range being replaced. This includes the prior summary message, when present. + /// + public IReadOnlyList SourceMessages { get; } + + /// + /// New history to merge into . It never contains the prior summary message itself. + /// + public IReadOnlyList Messages { get; } + + public string? PreviousSummary { get; } + + public GameTranscriptSummaryPurpose Purpose { get; } + + public int Attempt { get; } + + public string? PreviousError { get; } + + public long? TargetEstimatedTokens { get; } + + private static IReadOnlyList CopyMessages( + IReadOnlyList messages, + string parameterName) + { + var copied = (messages ?? throw new ArgumentNullException(parameterName)).ToArray(); + if (copied.Any(message => message is null)) { - throw new InvalidOperationException("The compacted transcript contains an unresolved tool call."); + throw new ArgumentException("Summary message collections cannot contain null values.", parameterName); } + + return Array.AsReadOnly(copied); + } +} + +public sealed class GameTranscriptSummaryAttemptResult +{ + private GameTranscriptSummaryAttemptResult( + bool succeeded, + string? summary, + ModelUsage? usage, + string? error, + bool retryable, + string? detailsJson) + { + if (succeeded == string.IsNullOrWhiteSpace(summary)) + { + throw new ArgumentException("A successful summary attempt requires summary text, and a failed attempt cannot include it."); + } + + if (!succeeded && string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A failed summary attempt requires an error message.", nameof(error)); + } + + Succeeded = succeeded; + Summary = succeeded ? summary : null; + Usage = usage ?? new ModelUsage(); + Error = succeeded ? null : error; + Retryable = !succeeded && retryable; + DetailsJson = detailsJson is null ? null : GameJson.RequireValid(detailsJson, nameof(detailsJson)); + } + + public bool Succeeded { get; } + + public string? Summary { get; } + + public ModelUsage Usage { get; } + + public string? Error { get; } + + public bool Retryable { get; } + + public string? DetailsJson { get; } + + public static GameTranscriptSummaryAttemptResult Success( + string summary, + ModelUsage? usage = null, + string? detailsJson = null) => + new(true, summary, usage, error: null, retryable: false, detailsJson); + + public static GameTranscriptSummaryAttemptResult Success(GameTranscriptSummaryResult result) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + + return Success(result.Summary, result.Usage, result.DetailsJson); + } + + public static GameTranscriptSummaryAttemptResult Failure( + string error, + ModelUsage? usage = null, + bool retryable = false, + string? detailsJson = null) => + new(false, summary: null, usage, error, retryable, detailsJson); +} + +public delegate ValueTask GameTranscriptSummaryAttemptHandler( + GameTranscriptSummaryContext context, + CancellationToken cancellationToken); + +public sealed class GameTranscriptSummaryAttemptDetails +{ + public GameTranscriptSummaryAttemptDetails( + int attempt, + bool succeeded, + bool retryable, + ModelUsage? usage = null, + string? error = null, + string? detailsJson = null) + { + Attempt = attempt > 0 ? attempt : throw new ArgumentOutOfRangeException(nameof(attempt)); + if (succeeded && !string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A successful summary attempt cannot include an error.", nameof(error)); + } + + if (!succeeded && string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A failed summary attempt requires an error.", nameof(error)); + } + + Succeeded = succeeded; + Retryable = !succeeded && retryable; + Usage = usage ?? new ModelUsage(); + Error = succeeded ? null : error; + DetailsJson = detailsJson is null ? null : GameJson.RequireValid(detailsJson, nameof(detailsJson)); + } + + public int Attempt { get; } + + public bool Succeeded { get; } + + public bool Retryable { get; } + + public ModelUsage Usage { get; } + + public string? Error { get; } + + public string? DetailsJson { get; } +} + +public enum GameTranscriptCompactionTrigger +{ + None, + MessageLimit, + TokenLimit, + MessageAndTokenLimit, +} + +public sealed class GameTranscriptCompactionDetails +{ + public GameTranscriptCompactionDetails( + int originalMessageCount, + int compactedMessageCount, + int retainedMessageCount, + long? estimatedTokensBefore = null, + string? summaryDetailsJson = null, + GameTranscriptCompactionTrigger trigger = GameTranscriptCompactionTrigger.None, + int? cutMessageIndex = null, + int incrementalMessageCount = 0, + int retainedTurnCount = 0, + bool previousSummaryUsed = false, + IReadOnlyList? summaryAttempts = null, + bool applied = true, + string? failureCode = null) + { + if (originalMessageCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(originalMessageCount)); + } + + if (compactedMessageCount < 0 || compactedMessageCount > originalMessageCount) + { + throw new ArgumentOutOfRangeException(nameof(compactedMessageCount)); + } + + if (retainedMessageCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(retainedMessageCount)); + } + + if (checked(compactedMessageCount + retainedMessageCount) != originalMessageCount) + { + throw new ArgumentException("Compacted and retained message counts must partition the original transcript."); + } + + if (estimatedTokensBefore is < 0) + { + throw new ArgumentOutOfRangeException(nameof(estimatedTokensBefore)); + } + + if (!Enum.IsDefined(typeof(GameTranscriptCompactionTrigger), trigger)) + { + throw new ArgumentOutOfRangeException(nameof(trigger)); + } + + if (cutMessageIndex is < 0 || cutMessageIndex > originalMessageCount) + { + throw new ArgumentOutOfRangeException(nameof(cutMessageIndex)); + } + + if (incrementalMessageCount < 0 || incrementalMessageCount > compactedMessageCount) + { + throw new ArgumentOutOfRangeException(nameof(incrementalMessageCount)); + } + + if (retainedTurnCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(retainedTurnCount)); + } + + var copiedAttempts = (summaryAttempts ?? Array.Empty()).ToArray(); + if (copiedAttempts.Any(attempt => attempt is null)) + { + throw new ArgumentException("Summary attempt collections cannot contain null values.", nameof(summaryAttempts)); + } + + if (copiedAttempts.Select(attempt => attempt.Attempt).Distinct().Count() != copiedAttempts.Length + || copiedAttempts.Where((attempt, index) => attempt.Attempt != index + 1).Any()) + { + throw new ArgumentException("Summary attempts must be ordered and consecutively numbered.", nameof(summaryAttempts)); + } + + if (applied && failureCode is not null) + { + throw new ArgumentException("An applied compaction cannot include a failure code.", nameof(failureCode)); + } + + OriginalMessageCount = originalMessageCount; + CompactedMessageCount = compactedMessageCount; + RetainedMessageCount = retainedMessageCount; + EstimatedTokensBefore = estimatedTokensBefore; + SummaryDetailsJson = summaryDetailsJson is null + ? null + : GameJson.RequireValid(summaryDetailsJson, nameof(summaryDetailsJson)); + Trigger = trigger; + CutMessageIndex = cutMessageIndex; + IncrementalMessageCount = incrementalMessageCount; + RetainedTurnCount = retainedTurnCount; + PreviousSummaryUsed = previousSummaryUsed; + SummaryAttempts = Array.AsReadOnly(copiedAttempts); + Applied = applied; + FailureCode = failureCode is null ? null : GameJson.RequireId(failureCode, nameof(failureCode)); + } + + public int OriginalMessageCount { get; } + + public int CompactedMessageCount { get; } + + public int RetainedMessageCount { get; } + + public long? EstimatedTokensBefore { get; } + + public string? SummaryDetailsJson { get; } + + public GameTranscriptCompactionTrigger Trigger { get; } + + public int? CutMessageIndex { get; } + + public int IncrementalMessageCount { get; } + + public int RetainedTurnCount { get; } + + public bool PreviousSummaryUsed { get; } + + public IReadOnlyList SummaryAttempts { get; } + + public int SummaryAttemptCount => SummaryAttempts.Count; + + public int FailedSummaryAttemptCount => SummaryAttempts.Count(attempt => !attempt.Succeeded); + + public bool Applied { get; } + + public string? FailureCode { get; } +} + +public sealed class GameTranscriptCompactionException : InvalidOperationException +{ + public GameTranscriptCompactionException( + string errorCode, + string message, + ModelUsage? usage, + GameTranscriptCompactionDetails details, + Exception? innerException = null) + : base(message, innerException) + { + ErrorCode = GameJson.RequireId(errorCode, nameof(errorCode)); + Usage = usage ?? new ModelUsage(); + Details = details ?? throw new ArgumentNullException(nameof(details)); + } + + public string ErrorCode { get; } + + public ModelUsage Usage { get; } + + public GameTranscriptCompactionDetails Details { get; } +} + +public sealed class GameTranscriptCompactionResult +{ + public GameTranscriptCompactionResult( + IReadOnlyList messages, + ModelUsage? usage, + GameTranscriptCompactionDetails details) + { + var copiedMessages = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); + if (copiedMessages.Any(message => message is null)) + { + throw new ArgumentException("A compacted transcript cannot contain null messages.", nameof(messages)); + } + + Messages = Array.AsReadOnly(copiedMessages); + Usage = usage ?? new ModelUsage(); + Details = details ?? throw new ArgumentNullException(nameof(details)); + } + + public IReadOnlyList Messages { get; } + + public ModelUsage Usage { get; } + + public GameTranscriptCompactionDetails Details { get; } +} + +public delegate ValueTask GameTranscriptSummarizer( + GameSessionKey session, + IReadOnlyList messages, + CancellationToken cancellationToken); + +public sealed class SummarizingGameTranscriptCompactor : IGameTranscriptCompactor +{ + private const string SummaryRole = "transcript_summary"; + private readonly GameTranscriptSummaryAttemptHandler _summarizer; + private readonly int _maxSummaryAttempts; + + public SummarizingGameTranscriptCompactor(GameTranscriptSummarizer summarizer) + { + if (summarizer is null) + { + throw new ArgumentNullException(nameof(summarizer)); + } + + _maxSummaryAttempts = 1; + _summarizer = async (request, cancellationToken) => + { + var result = await summarizer( + request.Session, + request.SourceMessages, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The transcript summarizer returned null."); + return GameTranscriptSummaryAttemptResult.Success(result); + }; + } + + public SummarizingGameTranscriptCompactor( + GameTranscriptSummaryAttemptHandler summarizer, + int maxSummaryAttempts = 3) + { + _summarizer = summarizer ?? throw new ArgumentNullException(nameof(summarizer)); + _maxSummaryAttempts = maxSummaryAttempts > 0 + ? maxSummaryAttempts + : throw new ArgumentOutOfRangeException(nameof(maxSummaryAttempts)); + } + + public async ValueTask CompactAsync( + GameTranscriptCompactionContext context, + CancellationToken cancellationToken) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var estimatedTokensBefore = EstimateBefore(context); + if (Fits(context, context.Messages)) + { + return new GameTranscriptCompactionResult( + context.Messages, + new ModelUsage(), + new GameTranscriptCompactionDetails( + context.Messages.Count, + compactedMessageCount: 0, + retainedMessageCount: context.Messages.Count, + estimatedTokensBefore)); + } + + GameTranscriptStructure.ValidateToolExchanges(context.Messages); + var trigger = GetTrigger(context, estimatedTokensBefore); + var keepCount = Math.Max(1, context.TargetMessageCount - 1); + var start = FindSafeSuffixStart(context, keepCount); + var removed = context.Messages.Take(start).ToArray(); + var previousSummaryIndex = FindPreviousSummaryIndex(removed); + var previousSummary = previousSummaryIndex < 0 + ? null + : ReadSummary(removed[previousSummaryIndex]); + var incrementalMessages = removed + .Where((_, index) => index != previousSummaryIndex) + .ToArray(); + var retained = context.Messages.Skip(start).ToArray(); + var retainedTurnCount = GameTranscriptStructure.CountTurns(retained); + var attempts = new List(); + var usages = new List(); + string? previousError = null; + + for (var attempt = 1; attempt <= _maxSummaryAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var attemptResult = await _summarizer( + new GameTranscriptSummaryContext( + context.Session, + removed, + incrementalMessages, + previousSummary, + GameTranscriptSummaryPurpose.Compaction, + attempt, + previousError, + context.TargetEstimatedTokens), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The transcript summarizer returned null."); + usages.Add(attemptResult.Usage); + + if (!attemptResult.Succeeded) + { + previousError = attemptResult.Error!; + attempts.Add(new GameTranscriptSummaryAttemptDetails( + attempt, + succeeded: false, + attemptResult.Retryable, + attemptResult.Usage, + previousError, + attemptResult.DetailsJson)); + var usageLimitReached = IsSummaryUsageLimitReached(context, usages); + if (attemptResult.Retryable && attempt < _maxSummaryAttempts && !usageLimitReached) + { + continue; + } + + throw CreateFailure( + usageLimitReached && attemptResult.Retryable + ? "summary_usage_limit_exceeded" + : "summary_failed", + usageLimitReached && attemptResult.Retryable + ? previousError + " No retry was started because the summary usage budget was exhausted." + : previousError, + context, + removed.Length, + start, + incrementalMessages.Length, + retainedTurnCount, + previousSummary is not null, + trigger, + estimatedTokensBefore, + usages, + attempts, + attemptResult.DetailsJson); + } + + var summaryMessage = CreateSummaryMessage(attemptResult.Summary!, removed.Length); + var result = new[] { summaryMessage }.Concat(retained).ToArray(); + GameTranscriptStructure.ValidateToolExchanges(result); + var targetError = result.Length > context.TargetMessageCount + ? "The generated summary exceeded the requested message target." + : context.TargetEstimatedTokens is { } tokenTarget + && Estimate(context, result) > tokenTarget + ? "The generated summary exceeded the requested token target." + : null; + if (targetError is not null) + { + previousError = targetError; + attempts.Add(new GameTranscriptSummaryAttemptDetails( + attempt, + succeeded: false, + retryable: true, + attemptResult.Usage, + targetError, + attemptResult.DetailsJson)); + var usageLimitReached = IsSummaryUsageLimitReached(context, usages); + if (attempt < _maxSummaryAttempts && !usageLimitReached) + { + continue; + } + + throw CreateFailure( + usageLimitReached + ? "summary_usage_limit_exceeded" + : "summary_target_exceeded", + usageLimitReached + ? targetError + " No retry was started because the summary usage budget was exhausted." + : targetError, + context, + removed.Length, + start, + incrementalMessages.Length, + retainedTurnCount, + previousSummary is not null, + trigger, + estimatedTokensBefore, + usages, + attempts, + attemptResult.DetailsJson); + } + + attempts.Add(new GameTranscriptSummaryAttemptDetails( + attempt, + succeeded: true, + retryable: false, + attemptResult.Usage, + detailsJson: attemptResult.DetailsJson)); + var details = CreateDetails( + context, + removed.Length, + start, + incrementalMessages.Length, + retainedTurnCount, + previousSummary is not null, + trigger, + estimatedTokensBefore, + attempts, + attemptResult.DetailsJson); + return new GameTranscriptCompactionResult( + result, + GameTranscriptSummaryUtilities.AggregateUsage(usages), + details); + } + + throw new InvalidOperationException("The transcript summary attempt loop ended unexpectedly."); + } + + private static long? EstimateBefore(GameTranscriptCompactionContext context) => + context.TokenEstimator is null ? null : Estimate(context, context.Messages); + + private static int FindSafeSuffixStart(GameTranscriptCompactionContext context, int keepCount) + { + var messages = context.Messages; + var desired = Math.Max(1, messages.Count - keepCount); + for (var index = desired; index < messages.Count; index++) + { + if (GameTranscriptStructure.IsCompleteTurnBoundary(messages, index)) + { + var projectedCount = checked(messages.Count - index + 1); + if (projectedCount > context.TargetMessageCount) + { + continue; + } + + if (context.TargetEstimatedTokens is { } tokenTarget) + { + // Leave half of the transcript budget available to the summary. This is + // conservative and the completed summary is checked again below. + var suffixTarget = Math.Max(1, tokenTarget / 2); + if (Estimate(context, messages.Skip(index).ToArray()) > suffixTarget) + { + continue; + } + } + + return index; + } + } + + // If no complete turn fits, replace the entire transcript with one summary. + return messages.Count; + } + + private static bool Fits(GameTranscriptCompactionContext context, IReadOnlyList messages) => + messages.Count <= context.TargetMessageCount + && (context.TargetEstimatedTokens is not { } target || Estimate(context, messages) <= target); + + private static long Estimate(GameTranscriptCompactionContext context, IReadOnlyList messages) + { + var estimate = context.TokenEstimator!(messages); + return estimate >= 0 + ? estimate + : throw new InvalidOperationException("The transcript token estimator returned a negative value."); + } + + private static GameTranscriptCompactionTrigger GetTrigger( + GameTranscriptCompactionContext context, + long? estimatedTokensBefore) + { + var messageLimit = context.Messages.Count > context.TargetMessageCount; + var tokenLimit = context.TargetEstimatedTokens is { } tokenTarget + && estimatedTokensBefore > tokenTarget; + return (messageLimit, tokenLimit) switch + { + (true, true) => GameTranscriptCompactionTrigger.MessageAndTokenLimit, + (true, false) => GameTranscriptCompactionTrigger.MessageLimit, + (false, true) => GameTranscriptCompactionTrigger.TokenLimit, + _ => GameTranscriptCompactionTrigger.None, + }; + } + + private static int FindPreviousSummaryIndex(IReadOnlyList messages) + { + for (var index = messages.Count - 1; index >= 0; index--) + { + if (messages[index].Role == AgentRole.Custom + && string.Equals(messages[index].CustomRole, SummaryRole, StringComparison.Ordinal)) + { + return index; + } + } + + return -1; + } + + private static string ReadSummary(AgentMessage message) + { + var summary = string.Join("\n", message.Content.OfType().Select(content => content.Text)); + return string.IsNullOrWhiteSpace(summary) + ? throw new InvalidOperationException("A prior transcript summary did not contain summary text.") + : summary; + } + + private static AgentMessage CreateSummaryMessage(string summary, int compactedMessageCount) => new( + AgentRole.Custom, + new AgentContent[] { new TextContent(summary) }, + DateTimeOffset.UtcNow, + customRole: SummaryRole, + metadata: new Dictionary + { + ["game.compacted_message_count"] = compactedMessageCount.ToString( + System.Globalization.CultureInfo.InvariantCulture), + }); + + private static GameTranscriptCompactionDetails CreateDetails( + GameTranscriptCompactionContext context, + int compactedMessageCount, + int cutMessageIndex, + int incrementalMessageCount, + int retainedTurnCount, + bool previousSummaryUsed, + GameTranscriptCompactionTrigger trigger, + long? estimatedTokensBefore, + IReadOnlyList attempts, + string? summaryDetailsJson, + bool applied = true, + string? failureCode = null) => new( + context.Messages.Count, + compactedMessageCount, + context.Messages.Count - compactedMessageCount, + estimatedTokensBefore, + summaryDetailsJson, + trigger, + cutMessageIndex, + incrementalMessageCount, + retainedTurnCount, + previousSummaryUsed, + attempts, + applied, + failureCode); + + private static GameTranscriptCompactionException CreateFailure( + string errorCode, + string error, + GameTranscriptCompactionContext context, + int compactedMessageCount, + int cutMessageIndex, + int incrementalMessageCount, + int retainedTurnCount, + bool previousSummaryUsed, + GameTranscriptCompactionTrigger trigger, + long? estimatedTokensBefore, + IReadOnlyList usages, + IReadOnlyList attempts, + string? summaryDetailsJson) => new( + errorCode, + error, + GameTranscriptSummaryUtilities.AggregateUsage(usages), + CreateDetails( + context, + compactedMessageCount, + cutMessageIndex, + incrementalMessageCount, + retainedTurnCount, + previousSummaryUsed, + trigger, + estimatedTokensBefore, + attempts, + summaryDetailsJson, + applied: false, + failureCode: errorCode)); + + private static bool IsSummaryUsageLimitReached( + GameTranscriptCompactionContext context, + IReadOnlyList usages) => + context.MaximumSummaryUsageTokens is { } maximum + && GameTranscriptSummaryUtilities.AggregateUsage(usages).TotalTokens >= maximum; +} + +public sealed class GameBranchSummaryDetails +{ + public GameBranchSummaryDetails( + int sourceMessageCount, + int summarizedMessageCount, + long? estimatedSourceTokens, + IReadOnlyList? summaryAttempts = null) + { + if (sourceMessageCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(sourceMessageCount)); + } + + if (summarizedMessageCount < 0 || summarizedMessageCount > sourceMessageCount) + { + throw new ArgumentOutOfRangeException(nameof(summarizedMessageCount)); + } + + if (estimatedSourceTokens is < 0) + { + throw new ArgumentOutOfRangeException(nameof(estimatedSourceTokens)); + } + + var copiedAttempts = (summaryAttempts ?? Array.Empty()).ToArray(); + if (copiedAttempts.Any(attempt => attempt is null) + || copiedAttempts.Where((attempt, index) => attempt.Attempt != index + 1).Any()) + { + throw new ArgumentException( + "Summary attempts must be non-null, ordered, and consecutively numbered.", + nameof(summaryAttempts)); + } + + SourceMessageCount = sourceMessageCount; + SummarizedMessageCount = summarizedMessageCount; + EstimatedSourceTokens = estimatedSourceTokens; + SummaryAttempts = Array.AsReadOnly(copiedAttempts); + } + + public int SourceMessageCount { get; } + + public int SummarizedMessageCount { get; } + + public int OmittedMessageCount => SourceMessageCount - SummarizedMessageCount; + + public long? EstimatedSourceTokens { get; } + + public IReadOnlyList SummaryAttempts { get; } + + public int SummaryAttemptCount => SummaryAttempts.Count; +} + +public sealed class GameBranchSummaryResult +{ + public GameBranchSummaryResult( + string summary, + IReadOnlyList summarizedMessages, + ModelUsage? usage, + GameBranchSummaryDetails details, + string? summaryDetailsJson = null) + { + Summary = string.IsNullOrWhiteSpace(summary) + ? throw new ArgumentException("A branch summary is required.", nameof(summary)) + : summary; + var copiedMessages = (summarizedMessages ?? throw new ArgumentNullException(nameof(summarizedMessages))).ToArray(); + if (copiedMessages.Any(message => message is null)) + { + throw new ArgumentException("Branch summary messages cannot contain null values.", nameof(summarizedMessages)); + } + + SummarizedMessages = Array.AsReadOnly(copiedMessages); + Usage = usage ?? new ModelUsage(); + Details = details ?? throw new ArgumentNullException(nameof(details)); + SummaryDetailsJson = summaryDetailsJson is null + ? null + : GameJson.RequireValid(summaryDetailsJson, nameof(summaryDetailsJson)); + } + + public string Summary { get; } + + public IReadOnlyList SummarizedMessages { get; } + + public ModelUsage Usage { get; } + + public GameBranchSummaryDetails Details { get; } + + public string? SummaryDetailsJson { get; } +} + +public sealed class GameBranchSummaryException : InvalidOperationException +{ + public GameBranchSummaryException( + string message, + ModelUsage? usage, + GameBranchSummaryDetails details) + : base(message) + { + Usage = usage ?? new ModelUsage(); + Details = details ?? throw new ArgumentNullException(nameof(details)); + } + + public ModelUsage Usage { get; } + + public GameBranchSummaryDetails Details { get; } +} + +/// +/// Summarizes an abandoned linear branch supplied by the host. It has no dependency on a session-tree implementation. +/// +public sealed class GameBranchSummarizer +{ + private readonly GameTranscriptSummaryAttemptHandler _summarizer; + private readonly int _maxSummaryAttempts; + + public GameBranchSummarizer(GameTranscriptSummarizer summarizer) + { + if (summarizer is null) + { + throw new ArgumentNullException(nameof(summarizer)); + } + + _maxSummaryAttempts = 1; + _summarizer = async (request, cancellationToken) => + { + var result = await summarizer( + request.Session, + request.SourceMessages, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The branch summarizer returned null."); + return GameTranscriptSummaryAttemptResult.Success(result); + }; + } + + public GameBranchSummarizer( + GameTranscriptSummaryAttemptHandler summarizer, + int maxSummaryAttempts = 3) + { + _summarizer = summarizer ?? throw new ArgumentNullException(nameof(summarizer)); + _maxSummaryAttempts = maxSummaryAttempts > 0 + ? maxSummaryAttempts + : throw new ArgumentOutOfRangeException(nameof(maxSummaryAttempts)); + } + + public ValueTask SummarizeAsync( + GameSessionKey session, + IReadOnlyList messages, + CancellationToken cancellationToken = default) => + SummarizeCoreAsync(session, messages, targetEstimatedTokens: null, tokenEstimator: null, cancellationToken); + + public ValueTask SummarizeAsync( + GameSessionKey session, + IReadOnlyList messages, + long targetEstimatedTokens, + GameTranscriptTokenEstimator tokenEstimator, + CancellationToken cancellationToken = default) => + SummarizeCoreAsync(session, messages, targetEstimatedTokens, tokenEstimator, cancellationToken); + + private async ValueTask SummarizeCoreAsync( + GameSessionKey session, + IReadOnlyList messages, + long? targetEstimatedTokens, + GameTranscriptTokenEstimator? tokenEstimator, + CancellationToken cancellationToken) + { + session.EnsureValid(nameof(session)); + var source = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); + if (source.Length == 0 || source.Any(message => message is null)) + { + throw new ArgumentException("A branch summary requires non-null messages.", nameof(messages)); + } + + if (targetEstimatedTokens is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(targetEstimatedTokens)); + } + + if (targetEstimatedTokens is not null && tokenEstimator is null) + { + throw new ArgumentException( + "A token estimator is required when a branch-summary token target is configured.", + nameof(tokenEstimator)); + } + + GameTranscriptStructure.ValidateToolExchanges(source); + long? estimatedSourceTokens = tokenEstimator is null + ? null + : GameTranscriptSummaryUtilities.ValidateEstimate(tokenEstimator(source)); + var selected = SelectBranchMessages(source, targetEstimatedTokens, tokenEstimator); + var attempts = new List(); + var usages = new List(); + string? previousError = null; + for (var attempt = 1; attempt <= _maxSummaryAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var attemptResult = await _summarizer( + new GameTranscriptSummaryContext( + session, + selected, + selected, + previousSummary: null, + GameTranscriptSummaryPurpose.Branch, + attempt, + previousError, + targetEstimatedTokens), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The branch summarizer returned null."); + usages.Add(attemptResult.Usage); + if (attemptResult.Succeeded) + { + attempts.Add(new GameTranscriptSummaryAttemptDetails( + attempt, + succeeded: true, + retryable: false, + attemptResult.Usage, + detailsJson: attemptResult.DetailsJson)); + return new GameBranchSummaryResult( + attemptResult.Summary!, + selected, + GameTranscriptSummaryUtilities.AggregateUsage(usages), + new GameBranchSummaryDetails(source.Length, selected.Length, estimatedSourceTokens, attempts), + attemptResult.DetailsJson); + } + + previousError = attemptResult.Error!; + attempts.Add(new GameTranscriptSummaryAttemptDetails( + attempt, + succeeded: false, + attemptResult.Retryable, + attemptResult.Usage, + previousError, + attemptResult.DetailsJson)); + if (!attemptResult.Retryable || attempt == _maxSummaryAttempts) + { + throw new GameBranchSummaryException( + previousError, + GameTranscriptSummaryUtilities.AggregateUsage(usages), + new GameBranchSummaryDetails(source.Length, selected.Length, estimatedSourceTokens, attempts)); + } + } + + throw new InvalidOperationException("The branch summary attempt loop ended unexpectedly."); + } + + private static AgentMessage[] SelectBranchMessages( + IReadOnlyList messages, + long? targetEstimatedTokens, + GameTranscriptTokenEstimator? tokenEstimator) + { + if (targetEstimatedTokens is null + || GameTranscriptSummaryUtilities.ValidateEstimate(tokenEstimator!(messages)) <= targetEstimatedTokens) + { + return messages.ToArray(); + } + + for (var index = 1; index < messages.Count; index++) + { + if (!GameTranscriptStructure.IsCompleteTurnBoundary(messages, index)) + { + continue; + } + + var candidate = messages.Skip(index).ToArray(); + if (GameTranscriptSummaryUtilities.ValidateEstimate(tokenEstimator(candidate)) <= targetEstimatedTokens) + { + return candidate; + } + } + + throw new InvalidOperationException("No complete branch turn fits the requested summary input budget."); + } +} + +internal static class GameTranscriptStructure +{ + public static bool IsCompleteTurnBoundary(IReadOnlyList messages, int index) + { + if (index <= 0 || index >= messages.Count + || messages[index].Role is not (AgentRole.User or AgentRole.Custom)) + { + return false; + } + + return HasCompleteToolExchanges(messages, 0, index) + && HasCompleteToolExchanges(messages, index, messages.Count); + } + + public static void ValidateToolExchanges(IReadOnlyList messages) + { + if (!HasCompleteToolExchanges(messages, 0, messages.Count)) + { + throw new InvalidOperationException( + "The transcript contains an orphan, unresolved, or duplicate tool exchange."); + } + } + + public static int CountTurns(IReadOnlyList messages) + { + if (messages.Count == 0) + { + return 0; + } + + var starts = messages.Count(message => message.Role is AgentRole.User or AgentRole.Custom); + return starts == 0 ? 1 : starts; + } + + private static bool HasCompleteToolExchanges( + IReadOnlyList messages, + int start, + int end) + { + var openCalls = new HashSet(StringComparer.Ordinal); + var seenCalls = new HashSet(StringComparer.Ordinal); + for (var index = start; index < end; index++) + { + var message = messages[index]; + if (message.Role == AgentRole.Assistant) + { + foreach (var call in message.Content.OfType()) + { + if (!seenCalls.Add(call.Id) || !openCalls.Add(call.Id)) + { + return false; + } + } + } + else if (message.Role == AgentRole.Tool) + { + if (message.ToolCallId is not { } callId || !openCalls.Remove(callId)) + { + return false; + } + } + } + + return openCalls.Count == 0; + } +} + +internal static class GameTranscriptSummaryUtilities +{ + public static long ValidateEstimate(long estimate) => estimate >= 0 + ? estimate + : throw new InvalidOperationException("The transcript token estimator returned a negative value."); + + public static ModelUsage AggregateUsage(IEnumerable values) + { + var usages = values.ToArray(); + if (usages.Length == 0) + { + return new ModelUsage(); + } + + if (usages.Length == 1) + { + return usages[0]; + } + + var hasReasoning = usages.Any(usage => usage.ReasoningTokens is not null); + var hasLongCacheWrite = usages.Any(usage => usage.CacheWriteOneHourTokens is not null); + return new ModelUsage( + usages.Aggregate(0L, (total, usage) => checked(total + usage.InputTokens)), + usages.Aggregate(0L, (total, usage) => checked(total + usage.OutputTokens)), + usages.Aggregate(0L, (total, usage) => checked(total + usage.CacheReadTokens)), + usages.Aggregate(0L, (total, usage) => checked(total + usage.CacheWriteTokens)), + hasReasoning + ? usages.Aggregate(0L, (total, usage) => checked(total + (usage.ReasoningTokens ?? 0))) + : null, + hasLongCacheWrite + ? usages.Aggregate(0L, (total, usage) => checked(total + (usage.CacheWriteOneHourTokens ?? 0))) + : null, + new ModelCost( + usages.Sum(usage => usage.Cost.Input), + usages.Sum(usage => usage.Cost.Output), + usages.Sum(usage => usage.Cost.CacheRead), + usages.Sum(usage => usage.Cost.CacheWrite))); } } diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json index 65accab..bffb11e 100644 --- a/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json @@ -228,7 +228,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -236,6 +237,12 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } } } } diff --git a/tests/OpenGameAgent.Extensions.Tests/packages.lock.json b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json index 2eee7f4..5e4c125 100644 --- a/tests/OpenGameAgent.Extensions.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json @@ -212,7 +212,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -220,6 +221,12 @@ "dependencies": { "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } } } } diff --git a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs index 743a6aa..a89ba4a 100644 --- a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs @@ -591,6 +591,59 @@ public async Task ToolProgressAfterSettlementIsIgnored() Assert.Equal(0, progress); } + [Fact] + public async Task SettledParallelToolProgressIsIgnoredWhileAnotherToolIsRunning() + { + var provider = ScriptedProvider.FromResponses( + Responses.Tools( + ModelStopReason.ToolUse, + new ToolCallContent("1", "fast", "{}"), + new ToolCallContent("2", "slow", "{}")), + Responses.Text("done")); + var fastEnded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var slowStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSlow = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ToolExecutionContext? fastContext = null; + var progress = 0; + var options = new AgentOptions(provider, "test"); + options.Tools.Add(Responses.Tool("fast", (_, context, _) => + { + fastContext = context; + return new ValueTask(Responses.Result("fast")); + }, mode: ToolExecutionMode.Parallel)); + options.Tools.Add(Responses.Tool("slow", async (_, _, _) => + { + slowStarted.TrySetResult(); + await releaseSlow.Task; + return Responses.Result("slow"); + }, mode: ToolExecutionMode.Parallel)); + var agent = new Agent(options); + agent.Subscribe((value, _) => + { + if (value.Kind == AgentEventKind.ToolEnded && value.ToolCall?.Id == "1") + { + fastEnded.TrySetResult(); + } + else if (value.Kind == AgentEventKind.ToolProgressed) + { + progress++; + } + + return ValueTask.CompletedTask; + }); + + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await Task.WhenAll(fastEnded.Task, slowStarted.Task) + .WaitAsync(TestContext.Current.CancellationToken); + await fastContext!.ReportProgressAsync( + new ToolProgress(content: new AgentContent[] { new TextContent("late") }), + TestContext.Current.CancellationToken); + Assert.Equal(0, progress); + + releaseSlow.TrySetResult(); + await run; + } + [Fact] public async Task AcceptedToolProgressSettlesBeforeToolEndEvenWhenToolDoesNotAwaitIt() { @@ -638,6 +691,42 @@ public async Task AcceptedToolProgressSettlesBeforeToolEndEvenWhenToolDoesNotAwa } } + [Fact] + public async Task OversizedToolProgressContentFailsTheToolBeforePublication() + { + var provider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "work", "{}")), + Responses.Text("done")); + var options = new AgentOptions(provider, "test") + { + Limits = new AgentLimits { MaxTextCharactersPerPart = 4 }, + }; + options.Tools.Add(Responses.Tool("work", async (_, context, cancellationToken) => + { + await context.ReportProgressAsync( + new ToolProgress(content: new AgentContent[] { new TextContent("too large") }), + cancellationToken); + return Responses.Result("done"); + })); + var progressCount = 0; + var agent = new Agent(options); + agent.Subscribe((value, _) => + { + if (value.Kind == AgentEventKind.ToolProgressed) + { + progressCount++; + } + + return ValueTask.CompletedTask; + }); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0, progressCount); + Assert.True(Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool).IsError); + } + [Fact] public async Task SteeringIsInjectedAfterCurrentToolBatch() { @@ -794,7 +883,7 @@ public async Task BlockedToolCanTerminateWithoutDispatching() { Hooks = new AgentHooks { - BeforeToolCallAsync = (_, _, _) => + BeforeToolCallAsync = (_, _) => new ValueTask(ToolCallDecision.Block("denied", terminate: true)), }, }; @@ -827,8 +916,8 @@ public async Task OneTerminatingBlockedToolDoesNotStopAMixedBatch() { Hooks = new AgentHooks { - BeforeToolCallAsync = (call, _, _) => - new ValueTask(call.Id == "blocked-1" + BeforeToolCallAsync = (context, _) => + new ValueTask(context.ToolCall.Id == "blocked-1" ? ToolCallDecision.Block("denied", terminate: true) : null), }, @@ -947,7 +1036,7 @@ public async Task CancellationDuringAfterToolHookDoesNotEraseCompletedToolResult { Hooks = new AgentHooks { - AfterToolCallAsync = (_, _, _, token) => + AfterToolCallAsync = (_, token) => { token.ThrowIfCancellationRequested(); return new ValueTask((ToolResult?)null); @@ -986,7 +1075,7 @@ public async Task CancellationDuringToolPreparationSettlesEveryUndispatchedCall( { Hooks = new AgentHooks { - BeforeToolCallAsync = (_, _, token) => + BeforeToolCallAsync = (_, token) => { cancellation.Cancel(); token.ThrowIfCancellationRequested(); @@ -1085,6 +1174,26 @@ public async Task ProviderExceptionBecomesNormalFailureLifecycle() Assert.Equal(ModelStopReason.Error, agent.State.Messages[^1].StopReason); } + [Fact] + public async Task StructuredProviderFailurePreservesDiagnostics() + { + var diagnostic = new ModelDiagnostic( + "provider_failure", + "Structured provider metadata is available.", + ModelDiagnosticSeverity.Error, + "{\"requestId\":\"request-1\"}"); + var provider = new ScriptedProvider((_, _, _) => + throw new ModelProviderException("offline", new[] { diagnostic })); + var agent = new Agent(new AgentOptions(provider, "test")); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.ProviderError, result.Status); + var failure = agent.State.Messages[^1]; + Assert.Equal("provider_failure", Assert.Single(failure.Diagnostics).Code); + Assert.Equal("{\"requestId\":\"request-1\"}", failure.Diagnostics[0].DataJson); + } + [Fact] public async Task SubscriberFailureDoesNotHideThePrimaryRunFailure() { @@ -1470,11 +1579,11 @@ public async Task BeforeToolHookOnlySeesSchemaValidArgumentsAndReplacementIsReva { Hooks = new AgentHooks { - BeforeToolCallAsync = (call, _, _) => + BeforeToolCallAsync = (context, _) => { hookCalls++; return new ValueTask( - call.Id == "invalid-replacement" + context.ToolCall.Id == "invalid-replacement" ? ToolCallDecision.Allow("{}") : ToolCallDecision.Allow()); }, @@ -1500,6 +1609,51 @@ public async Task BeforeToolHookOnlySeesSchemaValidArgumentsAndReplacementIsReva Assert.Equal(2, agent.State.Messages.Count(message => message.Role == AgentRole.Tool && message.IsError)); } + [Fact] + public async Task ToolHooksReceiveAssistantMessageValidatedArgumentsAndRunCoordinates() + { + var provider = ScriptedProvider.FromResponses( + Responses.Tools( + ModelStopReason.ToolUse, + new ToolCallContent("call", "inspect", "{\"value\":7}")), + Responses.Text("done")); + BeforeToolCallContext? before = null; + AfterToolCallContext? after = null; + var options = new AgentOptions(provider, "test") + { + Hooks = new AgentHooks + { + BeforeToolCallAsync = (context, _) => + { + before = context; + return new ValueTask((ToolCallDecision?)null); + }, + AfterToolCallAsync = (context, _) => + { + after = context; + return new ValueTask((ToolResult?)null); + }, + }, + }; + options.Tools.Add(Responses.Tool( + "inspect", + (_, _, _) => new ValueTask(Responses.Result("ok")), + schema: "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"]}")); + + var result = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.NotNull(before); + Assert.NotNull(after); + Assert.Equal(result.RunId, before.RunId); + Assert.Equal(1, before.Turn); + Assert.Equal("call", before.ToolCall.Id); + Assert.Equal(7, before.Arguments.GetProperty("value").GetInt32()); + Assert.Same(before.AssistantMessage, after.AssistantMessage); + Assert.Equal("ok", Assert.IsType(Assert.Single(after.Result.Content)).Text); + Assert.Equal(AgentRole.Assistant, before.Context.Messages.Last().Role); + } + [Fact] public async Task LoopSnapshotsLimitsBeforeToolHooksCanMutateOptions() { @@ -1511,7 +1665,7 @@ public async Task LoopSnapshotsLimitsBeforeToolHooksCanMutateOptions() { Limits = new AgentLimits { MaxJsonCharactersPerPart = 64 }, }; - options.Hooks.BeforeToolCallAsync = (_, _, _) => + options.Hooks.BeforeToolCallAsync = (_, _) => { options.Limits.MaxJsonCharactersPerPart = 1; return new ValueTask(ToolCallDecision.Allow("{}")); @@ -1675,6 +1829,7 @@ public async Task SubscribersReceiveTheActiveCancelableRunToken() }); var agent = new Agent(new AgentOptions(provider, "test")); var canBeCanceled = false; + var terminalObservedCancellation = false; agent.Subscribe((agentEvent, cancellationToken) => { if (agentEvent.Kind == AgentEventKind.RunStarted) @@ -1682,6 +1837,10 @@ public async Task SubscribersReceiveTheActiveCancelableRunToken() canBeCanceled = cancellationToken.CanBeCanceled; entered.TrySetResult(); } + else if (agentEvent.Kind == AgentEventKind.RunEnded) + { + terminalObservedCancellation = cancellationToken.IsCancellationRequested; + } return default; }); @@ -1692,6 +1851,7 @@ public async Task SubscribersReceiveTheActiveCancelableRunToken() var result = await run; Assert.True(canBeCanceled); + Assert.True(terminalObservedCancellation); Assert.Equal(AgentRunStatus.Aborted, result.Status); } @@ -2056,6 +2216,55 @@ public void StreamAndUsageContractsRejectAmbiguousTerminalState() Assert.Throws(() => ModelStreamEvent.Terminal(pending)); Assert.Throws(() => ModelStreamEvent.Update(ModelStreamEventKind.Started, complete)); Assert.Throws(() => ModelStreamEvent.Update(ModelStreamEventKind.TextDelta, pending)); + var toolCall = new ToolCallContent( + "call-1", + "inspect", + "{\"depth\":2}", + thoughtSignature: "opaque", + toolNamespace: "world"); + Assert.Throws(() => + ModelStreamEvent.Update(ModelStreamEventKind.ToolCallEnded, pending)); + Assert.Throws(() => + ModelStreamEvent.Update(ModelStreamEventKind.TextEnded, pending, toolCall: toolCall)); + Assert.Throws(() => + ModelStreamEvent.Update(ModelStreamEventKind.TextEnded, pending)); + Assert.Throws(() => + ModelStreamEvent.Update(ModelStreamEventKind.Started, pending, content: "unexpected")); + var textPartial = new ModelResponse( + new AgentContent[] { new TextContent("complete text") }, + ModelStopReason.Pending); + var textEnded = ModelStreamEvent.Update( + ModelStreamEventKind.TextEnded, + textPartial, + content: "complete text"); + Assert.Equal("complete text", textEnded.Content); + Assert.Equal(0, textEnded.ContentIndex); + Assert.Throws(() => + ModelStreamEvent.Update( + ModelStreamEventKind.TextEnded, + textPartial, + content: "different")); + var toolPartial = new ModelResponse(new AgentContent[] { toolCall }, ModelStopReason.Pending); + Assert.Throws(() => + ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + toolPartial, + toolCallId: "different", + toolCall: toolCall)); + Assert.Throws(() => + ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + toolPartial, + toolName: "different", + toolCall: toolCall)); + var toolEnded = ModelStreamEvent.Update( + ModelStreamEventKind.ToolCallEnded, + toolPartial, + toolCall: toolCall); + Assert.Same(toolCall, toolEnded.ToolCall); + Assert.Equal(toolCall.Id, toolEnded.ToolCallId); + Assert.Equal(toolCall.Name, toolEnded.ToolName); + Assert.Equal(0, toolEnded.ContentIndex); Assert.Throws(() => new ModelUsage(10_000_000_001)); Assert.Throws(() => new ModelResponse( Array.Empty(), @@ -2324,6 +2533,21 @@ public async Task IdleModelParametersCanBeReplacedWithoutSharingMutableState() Assert.Equal("\"game\"", agent.State.Parameters.Extensions["mode"]); } + [Fact] + public async Task IdleAgentCanReplaceSessionAffinityBetweenRuns() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("first"), Responses.Text("second")); + var agent = new Agent(new AgentOptions(provider, "test") { SessionId = "session-one" }); + + await agent.RunAsync("one", TestContext.Current.CancellationToken); + agent.SetSessionId("session-two"); + await agent.RunAsync("two", TestContext.Current.CancellationToken); + + Assert.Equal("session-two", agent.SessionId); + Assert.Equal("session-two", agent.State.SessionId); + Assert.Equal(new[] { "session-one", "session-two" }, provider.Requests.Select(request => request.SessionId)); + } + [Fact] public async Task IdleAgentCanAtomicallySwitchProviderAndModelBetweenRuns() { @@ -2386,6 +2610,7 @@ public async Task MutableRuntimeConfigurationCannotChangeDuringAnActiveRun() Assert.Throws(() => agent.SetHooks(new AgentHooks())); Assert.Throws(() => agent.SetToolExecution(ToolExecutionMode.Sequential)); + Assert.Throws(() => agent.SetSessionId("other")); release.TrySetResult(); Assert.True((await run).Succeeded); @@ -2402,7 +2627,7 @@ public async Task OversizedAfterToolHookResultBecomesBoundedToolError() Limits = new AgentLimits { MaxTextCharactersPerPart = 64 }, Hooks = new AgentHooks { - AfterToolCallAsync = (_, _, _, _) => new ValueTask( + AfterToolCallAsync = (_, _) => new ValueTask( new ToolResult(new AgentContent[] { new TextContent(new string('x', 100)) })), }, }; @@ -2433,6 +2658,18 @@ public void FloatingPointConfigurationRejectsNonFiniteValues() })); } + [Fact] + public void ToolProgressContentIsValidatedAndDefensivelyCopied() + { + var source = new AgentContent[] { new TextContent("preview") }; + var progress = new ToolProgress(content: source); + source[0] = new TextContent("changed"); + + Assert.Equal("preview", Assert.IsType(Assert.Single(progress.Content)).Text); + Assert.Throws(() => new ToolProgress( + content: new AgentContent[] { new ReasoningContent("private") })); + } + [Fact] public async Task PublicSnapshotsAndLifecycleCollectionsAreImmutableDefensiveCopies() { diff --git a/tests/OpenGameAgent.Kernel.Tests/ModelProviderExceptionTests.cs b/tests/OpenGameAgent.Kernel.Tests/ModelProviderExceptionTests.cs new file mode 100644 index 0000000..cf6a06f --- /dev/null +++ b/tests/OpenGameAgent.Kernel.Tests/ModelProviderExceptionTests.cs @@ -0,0 +1,40 @@ +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Kernel.Tests; + +public sealed class ModelProviderExceptionTests +{ + [Fact] + public void CombinedFailurePreservesRetryMetadataAndDiagnostics() + { + var diagnostic = new ModelDiagnostic( + "provider_failure", + "Structured metadata was returned.", + ModelDiagnosticSeverity.Error, + "{\"requestId\":\"request-1\"}"); + + var failure = new ModelProviderException( + "temporarily unavailable", + new[] { diagnostic }, + isTransient: true, + retryAfter: TimeSpan.FromSeconds(2), + statusCode: 503); + + Assert.True(failure.IsTransient); + Assert.Equal(TimeSpan.FromSeconds(2), failure.RetryAfter); + Assert.Equal(503, failure.StatusCode); + Assert.Same(diagnostic, Assert.Single(failure.Diagnostics)); + Assert.NotEqual(typeof(HttpRequestException), typeof(ModelProviderException).BaseType); + } + + [Fact] + public void CombinedFailureRejectsNegativeRetryDelay() + { + Assert.Throws(() => new ModelProviderException( + "invalid", + Array.Empty(), + isTransient: true, + retryAfter: TimeSpan.FromMilliseconds(-1))); + } +} diff --git a/tests/OpenGameAgent.Kernel.Tests/OpenGameAgent.Kernel.Tests.csproj b/tests/OpenGameAgent.Kernel.Tests/OpenGameAgent.Kernel.Tests.csproj index 4d6a551..50cbd1e 100644 --- a/tests/OpenGameAgent.Kernel.Tests/OpenGameAgent.Kernel.Tests.csproj +++ b/tests/OpenGameAgent.Kernel.Tests/OpenGameAgent.Kernel.Tests.csproj @@ -17,4 +17,7 @@ + + + diff --git a/tests/OpenGameAgent.Kernel.Tests/ProjectDependencyBoundaryTests.cs b/tests/OpenGameAgent.Kernel.Tests/ProjectDependencyBoundaryTests.cs new file mode 100644 index 0000000..e4fe5ae --- /dev/null +++ b/tests/OpenGameAgent.Kernel.Tests/ProjectDependencyBoundaryTests.cs @@ -0,0 +1,85 @@ +using System.Xml.Linq; +using Xunit; + +namespace OpenGameAgent.Kernel.Tests; + +public sealed class ProjectDependencyBoundaryTests +{ + [Fact] + public void ReusableAiFoundationDoesNotDependOnGameHostPackages() + { + var root = FindRepositoryRoot(); + var reusableProjects = new[] + { + "OpenGameAgent.Kernel", + "OpenGameAgent.Models", + "OpenGameAgent.Models.BuiltIn", + "OpenGameAgent.Providers.Anthropic", + "OpenGameAgent.Providers.Bedrock", + "OpenGameAgent.Providers.Google", + "OpenGameAgent.Providers.Mistral", + "OpenGameAgent.Providers.OpenAI", + "OpenGameAgent.Providers.OpenAICompatible", + "OpenGameAgent.Providers.Remote", + }; + var forbidden = new HashSet(StringComparer.Ordinal) + { + "OpenGameAgent", + "OpenGameAgent.Client", + "OpenGameAgent.Extensions", + "OpenGameAgent.Persistence", + "OpenGameAgent.Server", + }; + + foreach (var project in reusableProjects) + { + Assert.Equal("netstandard2.1", ReadTargetFramework(root, project)); + var references = ReadProjectReferences(root, project); + Assert.DoesNotContain(references, reference => forbidden.Contains(reference)); + } + } + + [Fact] + public void KernelIsDependencyFreeAndModelsOnlyDependsOnKernel() + { + var root = FindRepositoryRoot(); + + Assert.Empty(ReadProjectReferences(root, "OpenGameAgent.Kernel")); + Assert.Equal( + new[] { "OpenGameAgent.Kernel" }, + ReadProjectReferences(root, "OpenGameAgent.Models")); + } + + private static IReadOnlyList ReadProjectReferences(string root, string project) + { + var document = ReadProject(root, project); + return document.Descendants("ProjectReference") + .Select(element => element.Attribute("Include")?.Value) + .Where(value => value is not null) + .Select(value => Path.GetFileNameWithoutExtension(value!.Replace('\\', '/'))) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + } + + private static string ReadTargetFramework(string root, string project) => + ReadProject(root, project).Descendants("TargetFramework").Single().Value; + + private static XDocument ReadProject(string root, string project) => + XDocument.Load(Path.Combine(root, "src", project, project + ".csproj"), LoadOptions.None); + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "OpenGameAgent.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("The repository root could not be located."); + } +} diff --git a/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs b/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs new file mode 100644 index 0000000..3e4f813 --- /dev/null +++ b/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs @@ -0,0 +1,302 @@ +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Kernel.Tests; + +public sealed class ProtocolContractsTests +{ + [Fact] + public void ContentContractsPreserveProviderContinuityMetadata() + { + var text = new TextContent("answer", "text-signature", AgentTextPhase.FinalAnswer); + var reasoning = new ReasoningContent("", "encrypted-thinking", redacted: true); + var image = new BinaryContent(AgentMediaKind.Image, "aGVsbG8=", "image/png", "preview"); + var call = new ToolCallContent("call-1", "act", "{}", "thought-signature", "world"); + + Assert.Equal("text-signature", text.Signature); + Assert.Equal(AgentTextPhase.FinalAnswer, text.Phase); + Assert.True(reasoning.Redacted); + Assert.Equal(AgentMediaKind.Image, image.MediaKind); + Assert.Equal("thought-signature", call.ThoughtSignature); + Assert.Equal("world", call.Namespace); + } + + [Fact] + public void UsagePreservesReasoningLongCacheAndItemizedCost() + { + var usage = new ModelUsage( + inputTokens: 10, + outputTokens: 8, + cacheReadTokens: 3, + cacheWriteTokens: 4, + reasoningTokens: 5, + cacheWriteOneHourTokens: 2, + cost: new ModelCost(input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04)); + + Assert.Equal(25, usage.TotalTokens); + Assert.Equal(5, usage.ReasoningTokens); + Assert.Equal(2, usage.CacheWriteOneHourTokens); + Assert.Equal(0.37, usage.Cost.Total, 8); + Assert.Throws(() => new ModelUsage(outputTokens: 1, reasoningTokens: 2)); + Assert.Throws(() => new ModelUsage(cacheWriteTokens: 1, cacheWriteOneHourTokens: 2)); + } + + [Fact] + public async Task DeferredResponseIdentitySurvivesTheAgentLoop() + { + var handle = new DeferredModelHandle( + "provider", + "model", + "responses", + "response-1", + pollAfterMilliseconds: 250, + dataJson: "{\"cursor\":1}"); + var response = new ModelResponse( + Array.Empty(), + ModelStopReason.Deferred, + provider: "provider", + api: "responses", + responseModel: "model-2026", + responseId: "response-1", + rawStopReason: "background", + diagnostics: new[] { new ModelDiagnostic("queued", "The response is queued.") }, + deferred: handle); + var agent = new Agent(new AgentOptions(ScriptedProvider.FromResponses(response), "model")); + + var run = await agent.RunAsync(AgentMessage.User("start"), TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.Completed, run.Status); + var assistant = Assert.Single(run.NewMessages, message => message.Role == AgentRole.Assistant); + Assert.Equal(ModelStopReason.Deferred, assistant.StopReason); + Assert.Equal("provider", assistant.Provider); + Assert.Equal("responses", assistant.Api); + Assert.Equal("model-2026", assistant.ResponseModel); + Assert.Equal("response-1", assistant.ResponseId); + Assert.Equal("background", assistant.RawStopReason); + Assert.Same(handle, assistant.Deferred); + Assert.Equal("queued", Assert.Single(assistant.Diagnostics).Code); + } + + [Fact] + public void ToolResultsPreserveNewlyAvailableToolNames() + { + var call = new ToolCallContent("call", "load", "{}"); + var result = new ToolResult( + new AgentContent[] { new TextContent("loaded") }, + addedToolNames: new[] { "build", "inspect" }); + + var message = AgentMessage.ToolResult(call, result, DateTimeOffset.UnixEpoch); + + Assert.Equal(new[] { "build", "inspect" }, message.AddedToolNames); + Assert.Throws(() => new ToolResult( + Array.Empty(), + addedToolNames: new[] { "duplicate", "duplicate" })); + } + + [Fact] + public void ModelParametersCloneProviderNeutralRequestOptions() + { + var parameters = new ModelParameters + { + ReasoningLevel = "high", + ReasoningBudgets = new Dictionary { ["high"] = 8192 }, + SamplingParametersJson = "{\"top_p\":0.9}", + MetadataJson = "{\"user_id\":\"player\"}", + Transport = ModelTransport.WebSocket, + CacheRetention = ModelCacheRetention.Long, + WebSocketConnectTimeoutMilliseconds = 5000, + Deferred = true, + DeferredWindow = ModelDeferredWindow.OneHour, + }; + + var clone = parameters.Clone(); + + Assert.NotSame(parameters, clone); + Assert.Equal(8192, clone.ReasoningBudgets["high"]); + Assert.Equal("{\"top_p\":0.9}", clone.SamplingParametersJson); + Assert.Equal(ModelTransport.WebSocket, clone.Transport); + Assert.Equal(ModelCacheRetention.Long, clone.CacheRetention); + Assert.True(clone.Deferred); + Assert.Equal(ModelDeferredWindow.OneHour, clone.DeferredWindow); + } + + [Fact] + public void ConstrainedSamplingContractsAreExplicit() + { + var schema = new ToolDefinition( + "generate", + "Generate a value.", + "{\"type\":\"object\"}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var grammar = ToolConstrainedSampling.Grammar(openAiRegex: "[a-z]+"); + + Assert.Equal(ToolSchemaStrictness.Require, schema.ConstrainedSampling?.Strictness); + Assert.Equal("[a-z]+", grammar.OpenAiRegex); + Assert.Throws(() => ToolConstrainedSampling.Grammar()); + } + + [Fact] + public void ProviderTranscriptRemovesForeignOpaqueStateAndRepairsOrphanedToolCalls() + { + var sourceCall = new ToolCallContent("foreign|id", "move", "{\"x\":1}", "opaque-thought", "private"); + var assistant = new AgentMessage( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("visible-plan", "opaque-reasoning"), + new ReasoningContent("redacted", "opaque-redacted", redacted: true), + new TextContent("answer", "foreign-text-signature"), + sourceCall, + }, + DateTimeOffset.UnixEpoch, + model: "source-model", + stopReason: ModelStopReason.ToolUse, + provider: "source-provider", + api: "source-api"); + + var normalized = ProviderTranscript.Normalize( + new[] { assistant, AgentMessage.User("interrupt", DateTimeOffset.UnixEpoch.AddSeconds(1)) }, + "target-provider", + "target-api", + "target-model", + (id, _, _, _) => "normalized-id"); + + Assert.Equal(3, normalized.Count); + var replayedAssistant = normalized[0]; + Assert.Equal("visible-plan", Assert.IsType(replayedAssistant.Content[0]).Text); + Assert.Equal("answer", Assert.IsType(replayedAssistant.Content[1]).Text); + Assert.Null(Assert.IsType(replayedAssistant.Content[1]).Signature); + var replayedCall = Assert.IsType(replayedAssistant.Content[2]); + Assert.Equal("normalized-id", replayedCall.Id); + Assert.Null(replayedCall.ThoughtSignature); + Assert.Null(replayedCall.Namespace); + Assert.True(normalized[1].IsError); + Assert.Equal("normalized-id", normalized[1].ToolCallId); + Assert.Equal(AgentRole.User, normalized[2].Role); + } + + [Fact] + public void ProviderTranscriptKeepsContinuityDataForExactSameModel() + { + var assistant = new AgentMessage( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent(string.Empty, "opaque", redacted: true), + new TextContent("answer", "signature", AgentTextPhase.FinalAnswer), + }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.Stop, + provider: "provider", + api: "api"); + + var normalized = ProviderTranscript.Normalize(new[] { assistant }, "provider", "api", "model"); + + Assert.Same(assistant.Content[0], normalized[0].Content[0]); + Assert.Same(assistant.Content[1], normalized[0].Content[1]); + } + + public static IEnumerable ProviderHandoffPairs() + { + var protocols = new[] + { + (Provider: "anthropic", Api: "anthropic-messages", Model: "claude"), + (Provider: "amazon-bedrock", Api: "bedrock-converse-stream", Model: "claude"), + (Provider: "google", Api: "google-generative-ai", Model: "gemini"), + (Provider: "mistral", Api: "mistral-conversations", Model: "mistral"), + (Provider: "openai", Api: "openai-responses", Model: "gpt"), + (Provider: "openai-compatible", Api: "openai-completions", Model: "compatible"), + }; + + foreach (var source in protocols) + { + foreach (var target in protocols) + { + if (source != target) + { + yield return new object[] + { + source.Provider, + source.Api, + source.Model, + target.Provider, + target.Api, + target.Model, + }; + } + } + } + } + + [Theory] + [MemberData(nameof(ProviderHandoffPairs))] + public void ForeignProviderTranscriptsAreSafeAcrossEveryBuiltInProtocol( + string sourceProvider, + string sourceApi, + string sourceModel, + string targetProvider, + string targetApi, + string targetModel) + { + var originalCall = new ToolCallContent( + "call|with/foreign:symbols", + "inspect", + "{\"path\":\"README.md\"}", + "opaque-tool-state", + "source-only-namespace"); + var assistant = new AgentMessage( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("visible reasoning", "opaque-reasoning-state"), + new ReasoningContent("hidden reasoning", "opaque-redacted-state", redacted: true), + new TextContent("answer", "opaque-text-state", AgentTextPhase.FinalAnswer), + originalCall, + }, + DateTimeOffset.UnixEpoch, + model: sourceModel, + stopReason: ModelStopReason.ToolUse, + provider: sourceProvider, + api: sourceApi); + var result = AgentMessage.ToolResult( + originalCall, + new ToolResult(new AgentContent[] { new TextContent("done") }), + DateTimeOffset.UnixEpoch.AddSeconds(1)); + + var normalized = ProviderTranscript.Normalize( + new AgentMessage[] { assistant, result, AgentMessage.User("continue", DateTimeOffset.UnixEpoch.AddSeconds(2)) }, + targetProvider, + targetApi, + targetModel, + static (id, _, _, _) => id.Replace('|', '_').Replace('/', '_').Replace(':', '_')); + + Assert.Equal(3, normalized.Count); + var replayedAssistant = normalized[0]; + Assert.DoesNotContain(replayedAssistant.Content, content => content is ReasoningContent); + Assert.Collection( + replayedAssistant.Content, + content => + { + var text = Assert.IsType(content); + Assert.Equal("visible reasoning", text.Text); + Assert.Null(text.Signature); + }, + content => + { + var text = Assert.IsType(content); + Assert.Equal("answer", text.Text); + Assert.Null(text.Signature); + Assert.Null(text.Phase); + }, + content => + { + var call = Assert.IsType(content); + Assert.Equal("call_with_foreign_symbols", call.Id); + Assert.Null(call.ThoughtSignature); + Assert.Null(call.Namespace); + }); + Assert.Equal("call_with_foreign_symbols", normalized[1].ToolCallId); + Assert.Equal(AgentRole.User, normalized[2].Role); + } +} diff --git a/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs new file mode 100644 index 0000000..cf9a3a0 --- /dev/null +++ b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs @@ -0,0 +1,21 @@ +using OpenGameAgent.Kernel; +using OpenGameAgent.Testing; +using Xunit; + +namespace OpenGameAgent.Kernel.Tests; + +public sealed class PublicApiCompatibilityTests +{ + private const string ApprovedApiHash = "F2EEC0C444DCDB501C44D0DEE0E1C8047D979E04FFB4A5567F6C36CDD95EEB68"; + + [Fact] + public void KernelPublicApiMatchesTheApprovedStableSurface() + { + var surface = PublicApiSurface.Describe(typeof(Agent).Assembly); + var hash = PublicApiSurface.Hash(typeof(Agent).Assembly); + + Assert.True( + string.Equals(ApprovedApiHash, hash, StringComparison.Ordinal), + $"The Kernel public API changed. Review the complete surface below, then update the approved hash intentionally.\nHash: {hash}\n\n{surface}"); + } +} diff --git a/tests/OpenGameAgent.Kernel.Tests/StreamingJsonTests.cs b/tests/OpenGameAgent.Kernel.Tests/StreamingJsonTests.cs new file mode 100644 index 0000000..d2d6a53 --- /dev/null +++ b/tests/OpenGameAgent.Kernel.Tests/StreamingJsonTests.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Kernel.Tests; + +public sealed class StreamingJsonTests +{ + [Theory] + [InlineData(null, "{}")] + [InlineData("", "{}")] + [InlineData("{\"path\":\"README.md\"}", "{\"path\":\"README.md\"}")] + [InlineData("{\"path\":\"READ", "{\"path\":\"READ\"}")] + [InlineData("{\"depth\":", "{\"depth\":null}")] + [InlineData("{\"depth\":2,", "{\"depth\":2}")] + [InlineData("{\"items\":[1,2", "{\"items\":[1,2]}")] + [InlineData("not-json", "{}")] + public void ParseObjectAlwaysReturnsAValidObject(string? input, string expected) + { + var actual = StreamingJson.ParseObject(input); + + Assert.Equal(expected, actual); + using var document = JsonDocument.Parse(actual); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + } + + [Fact] + public void RepairEscapesRawControlsAndInvalidStringEscapes() + { + var repaired = StreamingJson.Repair("{\"path\":\"a\\q\nb\"}"); + + Assert.Equal("{\"path\":\"a\\\\q\\nb\"}", repaired); + Assert.Equal("{\"path\":\"a\\\\q\\nb\"}", StreamingJson.ParseObject(repaired)); + var parsed = StreamingJson.ParseWithRepair("{\"path\":\"a\\q\nb\"}"); + Assert.Equal("a\\q\nb", parsed.GetProperty("path").GetString()); + } + + [Fact] + public void ParseWithRepairDoesNotHideStructuralJsonErrors() + { + Assert.ThrowsAny(() => StreamingJson.ParseWithRepair("{\"value\":}")); + } + + [Fact] + public void ParseObjectRejectsExcessiveDepth() + { + var input = "{\"value\":" + new string('[', 129); + + Assert.Equal("{}", StreamingJson.ParseObject(input)); + } +} diff --git a/tests/OpenGameAgent.Media.Tests/GameMediaModelRegistryTests.cs b/tests/OpenGameAgent.Media.Tests/GameMediaModelRegistryTests.cs new file mode 100644 index 0000000..e8070f4 --- /dev/null +++ b/tests/OpenGameAgent.Media.Tests/GameMediaModelRegistryTests.cs @@ -0,0 +1,607 @@ +using OpenGameAgent.Kernel; +using OpenGameAgent.Media; +using OpenGameAgent.Models; +using Xunit; + +namespace OpenGameAgent.Media.Tests; + +public sealed class GameMediaModelRegistryTests +{ + [Fact] + public void RegistersListsReplacesAndRemovesMediaProviders() + { + using var registry = new GameMediaModelRegistry(new GameMediaModelRegistryOptions { MaxProviders = 2 }); + registry.Register(Registration("visual", new[] + { + Model("visual", "image", GameMediaKind.Image), + Model("visual", "video", GameMediaKind.Video), + })); + registry.Register(Registration("voice", new[] { Model("voice", "speech", GameMediaKind.Audio) })); + + Assert.Equal(new[] { "visual", "voice" }, registry.GetProviders().Select(item => item.Descriptor.ProviderId)); + Assert.Equal(3, registry.GetModels().Count); + Assert.Equal("speech", registry.GetModel("voice", "speech")?.ModelId); + + registry.Register( + Registration("visual", new[] { Model("visual", "new-image", GameMediaKind.Image) }), + replace: true); + Assert.Null(registry.GetModel("visual", "image")); + Assert.NotNull(registry.GetModel("visual", "new-image")); + Assert.True(registry.Unregister("voice")); + Assert.False(registry.Unregister("voice")); + } + + [Fact] + public async Task RefreshSharesInflightWorkAndPublishesOnlySuccessfulCatalogs() + { + var testCancellation = TestContext.Current.CancellationToken; + var refreshCalls = 0; + var release = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "dynamic", + new[] { Model("dynamic", "old", GameMediaKind.Image) }, + supportsDynamicModels: true, + refresh: async (_, _) => + { + Interlocked.Increment(ref refreshCalls); + return await release.Task.ConfigureAwait(false); + })); + + var first = registry.RefreshAsync("dynamic", testCancellation).AsTask(); + var second = registry.RefreshAsync("dynamic", testCancellation).AsTask(); + release.SetResult(new[] { Model("dynamic", "new", GameMediaKind.Video) }); + var results = await Task.WhenAll(first, second); + + Assert.Equal(1, refreshCalls); + Assert.All(results, result => Assert.Equal(GameMediaModelRefreshStatus.Updated, result.Status)); + Assert.Null(registry.GetModel("dynamic", "old")); + Assert.NotNull(registry.GetModel("dynamic", "new")); + + registry.Register(Registration( + "dynamic", + new[] { Model("dynamic", "stable", GameMediaKind.Audio) }, + supportsDynamicModels: true, + refresh: (_, _) => throw new InvalidOperationException("offline")), + replace: true); + var failed = await registry.RefreshAsync("dynamic", testCancellation); + Assert.Equal(GameMediaModelRefreshStatus.Failed, failed.Status); + Assert.Equal("offline", failed.ErrorMessage); + Assert.NotNull(registry.GetModel("dynamic", "stable")); + } + + [Fact] + public async Task CancelingOneRefreshWaiterDoesNotCancelSharedWork() + { + var testCancellation = TestContext.Current.CancellationToken; + var refreshCalls = 0; + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "shared", + new[] { Model("shared", "old", GameMediaKind.Image) }, + supportsDynamicModels: true, + refresh: async (_, cancellationToken) => + { + Interlocked.Increment(ref refreshCalls); + started.TrySetResult(true); + return await release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + })); + + using var firstCancellation = new CancellationTokenSource(); + var first = registry.RefreshAsync("shared", firstCancellation.Token).AsTask(); + await started.Task.WaitAsync(testCancellation); + var second = registry.RefreshAsync("shared", testCancellation).AsTask(); + firstCancellation.Cancel(); + + var canceled = await first; + Assert.Equal(GameMediaModelRefreshStatus.Canceled, canceled.Status); + Assert.False(second.IsCompleted); + + release.SetResult(new[] { Model("shared", "new", GameMediaKind.Video) }); + var completed = await second; + Assert.Equal(GameMediaModelRefreshStatus.Updated, completed.Status); + Assert.Equal(1, refreshCalls); + Assert.NotNull(registry.GetModel("shared", "new")); + } + + [Fact] + public async Task ReplacementCancelsAStaleRefreshWithoutPublishingItsModels() + { + var testCancellation = TestContext.Current.CancellationToken; + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "replaceable", + new[] { Model("replaceable", "old", GameMediaKind.Image) }, + supportsDynamicModels: true, + refresh: async (_, cancellationToken) => + { + started.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return Array.Empty(); + })); + + var refresh = registry.RefreshAsync("replaceable", testCancellation).AsTask(); + await started.Task; + registry.Register( + Registration("replaceable", new[] { Model("replaceable", "current", GameMediaKind.Video) }), + replace: true); + + var result = await refresh; + Assert.Equal(GameMediaModelRefreshStatus.StaleRegistration, result.Status); + Assert.NotNull(registry.GetModel("replaceable", "current")); + Assert.Null(registry.GetModel("replaceable", "old")); + } + + [Fact] + public async Task ResolvesSharedAuthenticationAndDispatchesByModelForAllMediaKinds() + { + var testCancellation = TestContext.Current.CancellationToken; + var resolution = new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.BearerToken, "secret"), + "test", + new Uri("https://authenticated.example/v1"), + new Dictionary + { + ["X-Shared"] = "auth", + ["X-Auth"] = "yes", + ["X-Remove"] = null, + }, + new Dictionary { ["region"] = "test" }); + var authentication = new TestAuthentication(true, resolution); + var invocations = new List(); + var progress = new List(); + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "creator", + new[] + { + Model("creator", "image", GameMediaKind.Image, headers: new Dictionary + { + ["X-Shared"] = "model", + ["X-Model"] = "yes", + ["X-Remove"] = "model", + }), + Model("creator", "audio", GameMediaKind.Audio), + Model("creator", "video", GameMediaKind.Video), + }, + authentication: authentication, + factory: invocation => + { + invocations.Add(invocation); + return new DelegateGenerator(async (request, report, cancellationToken) => + { + if (report is not null) + { + await report(new GameMediaGenerationProgress("working", 0.5), cancellationToken); + } + + return Result(request.Kind); + }); + })); + + foreach (var pair in new[] + { + (Model: "image", Kind: GameMediaKind.Image), + (Model: "audio", Kind: GameMediaKind.Audio), + (Model: "video", Kind: GameMediaKind.Video), + }) + { + var generated = await registry.GenerateAsync( + "creator", + pair.Model, + Request(pair.Kind), + (update, _) => + { + progress.Add(update); + return ValueTask.CompletedTask; + }, + testCancellation); + Assert.Equal(GameMediaModelGenerationStatus.Completed, generated.Status); + Assert.NotNull(generated.Result); + } + + Assert.Equal(3, authentication.CheckCalls); + Assert.Equal(3, authentication.ResolveCalls); + Assert.Equal(3, invocations.Count); + Assert.All(invocations, invocation => Assert.Equal(new Uri("https://authenticated.example/v1"), invocation.Endpoint)); + Assert.Equal("auth", invocations[0].Headers["X-Shared"]); + Assert.Equal("yes", invocations[0].Headers["X-Model"]); + Assert.Equal("yes", invocations[0].Headers["X-Auth"]); + Assert.DoesNotContain("X-Remove", invocations[0].Headers.Keys); + Assert.Equal("test", invocations[0].Configuration["region"]); + Assert.Equal(3, progress.Count); + } + + [Fact] + public async Task UnknownModelsCapabilitiesAndAuthenticationFailInBand() + { + var testCancellation = TestContext.Current.CancellationToken; + var invocations = 0; + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "images", + new[] { Model("images", "draw", GameMediaKind.Image) }, + factory: _ => + { + invocations++; + return new DelegateGenerator((request, _, _) => new ValueTask(Result(request.Kind))); + })); + + var unknownProvider = await registry.GenerateAsync( + "missing", "draw", Request(GameMediaKind.Image), cancellationToken: testCancellation); + var unknownModel = await registry.GenerateAsync( + "images", "missing", Request(GameMediaKind.Image), cancellationToken: testCancellation); + var wrongKind = await registry.GenerateAsync( + "images", "draw", Request(GameMediaKind.Video), cancellationToken: testCancellation); + + Assert.Equal("provider_not_found", unknownProvider.ErrorCode); + Assert.Equal("model_not_found", unknownModel.ErrorCode); + Assert.Equal("capability_mismatch", wrongKind.ErrorCode); + Assert.Equal(0, invocations); + + registry.Register(Registration( + "locked", + new[] { Model("locked", "draw", GameMediaKind.Image) }, + authentication: new TestAuthentication(false, null)), + replace: false); + var unconfigured = await registry.GenerateAsync( + "locked", "draw", Request(GameMediaKind.Image), cancellationToken: testCancellation); + Assert.Equal(GameMediaModelGenerationStatus.Failed, unconfigured.Status); + Assert.Equal("authentication_unconfigured", unconfigured.ErrorCode); + } + + [Fact] + public async Task UnknownSourceMediaTypesFailClosedBeforeProviderInvocation() + { + var invocations = 0; + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "images", + new[] + { + Model( + "images", + "edit", + GameMediaKind.Image, + GameModelInputCapabilities.Text | GameModelInputCapabilities.Image), + }, + factory: _ => + { + invocations++; + return new DelegateGenerator((request, _, _) => + new ValueTask(Result(request.Kind))); + })); + + var result = await registry.GenerateAsync( + "images", + "edit", + new GameMediaGenerationRequest( + "unknown-source", + GameMediaKind.Image, + "{}", + prompt: "edit", + sources: new[] { new ResourceContent("memory://unknown", "application/octet-stream") }), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, result.Status); + Assert.Equal("capability_mismatch", result.ErrorCode); + Assert.Equal(0, invocations); + } + + [Fact] + public async Task CancellationAndTimeoutReturnTerminalResultsEvenWhenAGeneratorIgnoresTokens() + { + var testCancellation = TestContext.Current.CancellationToken; + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var registry = new GameMediaModelRegistry(new GameMediaModelRegistryOptions + { + GenerationTimeout = TimeSpan.FromMilliseconds(100), + }); + registry.Register(Registration( + "slow", + new[] { Model("slow", "render", GameMediaKind.Video) }, + factory: _ => new DelegateGenerator((_, _, _) => new ValueTask(completion.Task)))); + + using var canceled = new CancellationTokenSource(25); + var canceledResult = await registry.GenerateAsync( + "slow", + "render", + Request(GameMediaKind.Video), + cancellationToken: canceled.Token); + Assert.Equal(GameMediaModelGenerationStatus.Canceled, canceledResult.Status); + Assert.Equal("canceled", canceledResult.ErrorCode); + + var timeoutResult = await registry.GenerateAsync( + "slow", "render", Request(GameMediaKind.Video), cancellationToken: testCancellation); + Assert.Equal(GameMediaModelGenerationStatus.Failed, timeoutResult.Status); + Assert.Equal("timeout", timeoutResult.ErrorCode); + completion.TrySetResult(Result(GameMediaKind.Video)); + } + + [Fact] + public async Task RefreshAndProgressCallbackTimeoutsFailInBand() + { + var testCancellation = TestContext.Current.CancellationToken; + var refreshCompletion = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + var progressCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registry = new GameMediaModelRegistry(new GameMediaModelRegistryOptions + { + RefreshTimeout = TimeSpan.FromMilliseconds(100), + ProgressCallbackTimeout = TimeSpan.FromMilliseconds(100), + }); + registry.Register(Registration( + "timeouts", + new[] { Model("timeouts", "draw", GameMediaKind.Image) }, + supportsDynamicModels: true, + refresh: (_, _) => new ValueTask>(refreshCompletion.Task), + factory: _ => new DelegateGenerator(async (request, report, cancellationToken) => + { + if (report is not null) + { + await report(new GameMediaGenerationProgress("working"), cancellationToken); + } + + return Result(request.Kind); + }))); + + var refresh = await registry.RefreshAsync("timeouts", testCancellation); + Assert.Equal(GameMediaModelRefreshStatus.Failed, refresh.Status); + Assert.Contains("timed out", refresh.ErrorMessage, StringComparison.Ordinal); + Assert.NotNull(registry.GetModel("timeouts", "draw")); + + var generated = await registry.GenerateAsync( + "timeouts", + "draw", + Request(GameMediaKind.Image), + (_, _) => new ValueTask(progressCompletion.Task), + testCancellation); + Assert.Equal(GameMediaModelGenerationStatus.Failed, generated.Status); + Assert.Equal("generation_failed", generated.ErrorCode); + Assert.Contains("progress callback timed out", generated.ErrorMessage, StringComparison.Ordinal); + refreshCompletion.TrySetResult(Array.Empty()); + progressCompletion.TrySetResult(true); + } + + [Fact] + public async Task RequestResultAndProgressLimitsFailInBand() + { + var testCancellation = TestContext.Current.CancellationToken; + using var registry = new GameMediaModelRegistry(new GameMediaModelRegistryOptions + { + MaxSources = 1, + MaxOutputs = 1, + MaxProgressEvents = 1, + MaxJsonBytes = 128, + }); + registry.Register(Registration( + "bounded", + new[] + { + Model( + "bounded", + "draw", + GameMediaKind.Image, + inputs: GameModelInputCapabilities.Text | GameModelInputCapabilities.Image), + }, + factory: _ => new DelegateGenerator(async (_, report, cancellationToken) => + { + if (report is not null) + { + await report(new GameMediaGenerationProgress("one"), cancellationToken); + await report(new GameMediaGenerationProgress("two"), cancellationToken); + } + + return new GameMediaGenerationResult(new[] + { + new ResourceContent("memory://one", "image/png"), + new ResourceContent("memory://two", "image/png"), + }); + }))); + + var tooManySources = await registry.GenerateAsync( + "bounded", + "draw", + new GameMediaGenerationRequest( + "many", + GameMediaKind.Image, + "{}", + sources: new[] + { + new ResourceContent("memory://one", "image/png"), + new ResourceContent("memory://two", "image/png"), + }), + cancellationToken: testCancellation); + Assert.Equal("request_limit", tooManySources.ErrorCode); + + var oversizedJson = await registry.GenerateAsync( + "bounded", + "draw", + new GameMediaGenerationRequest( + "oversized", + GameMediaKind.Image, + System.Text.Json.JsonSerializer.Serialize(new { value = new string('x', 200) })), + cancellationToken: testCancellation); + Assert.Equal("request_limit", oversizedJson.ErrorCode); + + var progressFailure = await registry.GenerateAsync( + "bounded", + "draw", + Request(GameMediaKind.Image), + (_, _) => ValueTask.CompletedTask, + testCancellation); + Assert.Equal("generation_failed", progressFailure.ErrorCode); + Assert.Contains("progress event limit", progressFailure.ErrorMessage, StringComparison.Ordinal); + + registry.Register(Registration( + "bounded", + new[] { Model("bounded", "draw", GameMediaKind.Image) }, + factory: _ => new DelegateGenerator((_, _, _) => + new ValueTask(new GameMediaGenerationResult(new[] + { + new ResourceContent("memory://one", "image/png"), + new ResourceContent("memory://two", "image/png"), + })))), + replace: true); + var invalidResult = await registry.GenerateAsync( + "bounded", "draw", Request(GameMediaKind.Image), cancellationToken: testCancellation); + Assert.Equal("invalid_result", invalidResult.ErrorCode); + } + + [Fact] + public async Task RefreshAllIsConcurrentAndBestEffort() + { + var testCancellation = TestContext.Current.CancellationToken; + using var registry = new GameMediaModelRegistry(); + registry.Register(Registration( + "good", + Array.Empty(), + supportsDynamicModels: true, + refresh: (_, _) => new ValueTask>( + new[] { Model("good", "voice", GameMediaKind.Audio) }))); + registry.Register(Registration( + "bad", + new[] { Model("bad", "stable", GameMediaKind.Video) }, + supportsDynamicModels: true, + refresh: (_, _) => throw new InvalidOperationException("unavailable"))); + + var results = await registry.RefreshAsync(cancellationToken: testCancellation); + Assert.Equal(2, results.Count); + Assert.Contains(results, item => item.ProviderId == "good" && item.Status == GameMediaModelRefreshStatus.Updated); + Assert.Contains(results, item => item.ProviderId == "bad" && item.Status == GameMediaModelRefreshStatus.Failed); + Assert.NotNull(registry.GetModel("bad", "stable")); + } + + private static GameMediaProviderRegistration Registration( + string providerId, + IReadOnlyList models, + bool supportsDynamicModels = false, + GameMediaModelRefresh? refresh = null, + IGameProviderAuthentication? authentication = null, + GameMediaGeneratorFactory? factory = null) => + new( + new GameProviderDescriptor( + providerId, + endpoint: new Uri("https://provider.example/v1"), + supportsDynamicModels: supportsDynamicModels), + authentication ?? new StaticGameProviderAuthentication(), + factory ?? (_ => new DelegateGenerator((request, _, _) => + new ValueTask(Result(request.Kind)))), + models, + refresh); + + private static GameModelDescriptor Model( + string providerId, + string modelId, + GameMediaKind kind, + GameModelInputCapabilities inputs = GameModelInputCapabilities.Text, + IReadOnlyDictionary? headers = null) => + new( + providerId, + modelId, + inputCapabilities: inputs, + outputCapabilities: kind switch + { + GameMediaKind.Image => GameModelOutputCapabilities.Image, + GameMediaKind.Audio => GameModelOutputCapabilities.Audio, + GameMediaKind.Video => GameModelOutputCapabilities.Video, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }, + api: "media-test", + baseUrl: new Uri("https://model.example/v1"), + headers: headers); + + private static GameMediaGenerationRequest Request(GameMediaKind kind) => + new("request", kind, "{}", prompt: "create media"); + + private static GameMediaGenerationResult Result(GameMediaKind kind) => + new( + new[] + { + new ResourceContent( + "memory://generated", + kind switch + { + GameMediaKind.Image => "image/png", + GameMediaKind.Audio => "audio/wav", + GameMediaKind.Video => "video/mp4", + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }), + }, + "{\"usage\":1}", + "provider-request"); + + private sealed class DelegateGenerator : IGameMediaGenerator + { + private readonly Func< + GameMediaGenerationRequest, + GameMediaProgressHandler?, + CancellationToken, + ValueTask> _generate; + + public DelegateGenerator(Func< + GameMediaGenerationRequest, + GameMediaProgressHandler?, + CancellationToken, + ValueTask> generate) + { + _generate = generate; + } + + public ValueTask GenerateAsync( + GameMediaGenerationRequest request, + GameMediaProgressHandler? progress, + CancellationToken cancellationToken) => + _generate(request, progress, cancellationToken); + } + + private sealed class TestAuthentication : IGameProviderAuthentication + { + private readonly bool _configured; + private readonly GameProviderAuthResolution? _resolution; + + public TestAuthentication(bool configured, GameProviderAuthResolution? resolution) + { + _configured = configured; + _resolution = resolution; + } + + public int CheckCalls { get; private set; } + + public int ResolveCalls { get; private set; } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CheckCalls++; + return new ValueTask(new GameProviderAuthStatus( + _configured, + "test", + error: _configured ? null : "not configured")); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ResolveCalls++; + return new ValueTask(_resolution); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException(); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException(); + } +} diff --git a/tests/OpenGameAgent.Media.Tests/OpenGameAgent.Media.Tests.csproj b/tests/OpenGameAgent.Media.Tests/OpenGameAgent.Media.Tests.csproj new file mode 100644 index 0000000..069c119 --- /dev/null +++ b/tests/OpenGameAgent.Media.Tests/OpenGameAgent.Media.Tests.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0 + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Media.Tests/packages.lock.json b/tests/OpenGameAgent.Media.Tests/packages.lock.json new file mode 100644 index 0000000..bf5656c --- /dev/null +++ b/tests/OpenGameAgent.Media.Tests/packages.lock.json @@ -0,0 +1,233 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.media": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/BuiltInGameProviderAuthenticationTests.cs b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/BuiltInGameProviderAuthenticationTests.cs new file mode 100644 index 0000000..e204f2c --- /dev/null +++ b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/BuiltInGameProviderAuthenticationTests.cs @@ -0,0 +1,670 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.Models; +using OpenGameAgent.Models.Auth.BuiltIn; +using OpenGameAgent.Models.BuiltIn; +using Xunit; + +namespace OpenGameAgent.Models.Auth.BuiltIn.Tests; + +public sealed class BuiltInGameProviderAuthenticationTests +{ + [Fact] + public async Task OpenRouterUsesOneShotLoopbackPathStateAndPkceBeforeStoringTheKey() + { + string? exchangeBody = null; + var handler = new DelegateHandler(async (request, cancellationToken) => + { + exchangeBody = await request.Content!.ReadAsStringAsync(cancellationToken); + return Json(HttpStatusCode.OK, "{\"key\":\"router-key\"}"); + }); + var store = new InMemoryGameCredentialStore(); + var authentication = BuiltInGameProviderAuthentications.CreateOpenRouter( + Options(handler, store)); + Uri? authorizationUri = null; + Task? callback = null; + using var callbackClient = new HttpClient(); + var interaction = new GameAuthInteraction + { + OpenBrowserAsync = (uri, _) => + { + authorizationUri = uri; + var fields = ParseQuery(uri.Query); + var callbackUri = new Uri(fields["callback_url"] + "?code=authorization-code"); + callback = callbackClient.GetAsync(callbackUri); + return default; + }, + }; + + var credential = await authentication.LoginAsync( + "oauth-openrouter", + interaction, + TestContext.Current.CancellationToken); + + using var callbackResponse = await callback!; + Assert.Equal(HttpStatusCode.OK, callbackResponse.StatusCode); + Assert.Equal(GameCredentialKind.OAuth, credential.Kind); + Assert.Equal("router-key", credential.Secret); + Assert.NotNull(authorizationUri); + var authorization = ParseQuery(authorizationUri!.Query); + var callbackUrl = new Uri(authorization["callback_url"]); + Assert.Equal("127.0.0.1", callbackUrl.Host); + Assert.Matches("^/oauth/callback/[A-Za-z0-9_-]+$", callbackUrl.AbsolutePath); + Assert.Equal("S256", authorization["code_challenge_method"]); + + using var exchange = JsonDocument.Parse(exchangeBody!); + var verifier = exchange.RootElement.GetProperty("code_verifier").GetString()!; + Assert.Equal( + authorization["code_challenge"], + Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)))); + var stored = await store.GetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.OpenRouterProviderId), + TestContext.Current.CancellationToken); + Assert.Equal("router-key", stored?.Secret); + } + + [Fact] + public async Task LoopbackRejectsWrongHostAndStateThenAcceptsTheExactCallback() + { + var callbacks = new List(); + Task? callbackSequence = null; + Uri? redirect = null; + var options = new SecureLoopbackOAuthOptions( + new Uri("https://authorization.example/login"), + "127.0.0.1", + "/callback", + TimeSpan.FromSeconds(20), + (redirectUri, challenge, state) => + { + redirect = redirectUri; + return BuildUri( + new Uri("https://authorization.example/login"), + new Dictionary + { + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["code_challenge"] = challenge, + ["state"] = state, + }); + }, + (code, _, _, _, _) => new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "access-" + code)), + statePlacement: LoopbackStatePlacement.Query); + var interaction = new GameAuthInteraction + { + OpenBrowserAsync = (authorization, _) => + { + var state = ParseQuery(authorization.Query)["state"]; + callbackSequence = Task.Run(async () => + { + callbacks.Add(await SendRawCallbackAsync(redirect!, "evil.example", "?code=ignored&state=" + state)); + using var client = new HttpClient(); + using var wrongState = await client.GetAsync(new Uri(redirect + "?code=ignored&state=wrong")); + callbacks.Add(wrongState.StatusCode); + using var valid = await client.GetAsync(new Uri( + redirect + "?code=accepted&state=" + Uri.EscapeDataString(state))); + callbacks.Add(valid.StatusCode); + }); + return default; + }, + }; + + var credential = await SecureLoopbackOAuth.LoginAsync( + options, + interaction, + TestContext.Current.CancellationToken); + await callbackSequence!; + + Assert.Equal("access-accepted", credential.Secret); + Assert.Equal( + new[] { HttpStatusCode.BadRequest, HttpStatusCode.BadRequest, HttpStatusCode.OK }, + callbacks); + } + + [Fact] + public async Task XaiDeviceFlowHonorsPendingSlowDownAndVerificationHostBounds() + { + var requests = new List(); + var responses = new Queue(new[] + { + Json(HttpStatusCode.OK, """ + {"device_code":"device","user_code":"ABCD","verification_uri":"https://accounts.x.ai/oauth2/device","expires_in":900,"interval":5} + """), + Json(HttpStatusCode.BadRequest, "{\"error\":\"authorization_pending\"}"), + Json(HttpStatusCode.BadRequest, "{\"error\":\"slow_down\",\"interval\":9}"), + Json(HttpStatusCode.OK, "{\"access_token\":\"xai-access\",\"refresh_token\":\"xai-refresh\",\"expires_in\":3600}"), + }); + var handler = new DelegateHandler(async (request, cancellationToken) => + { + requests.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + return responses.Dequeue(); + }); + var delays = new List(); + var store = new InMemoryGameCredentialStore(); + var options = Options(handler, store); + options.DelayAsync = (delay, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + delays.Add(delay); + return Task.CompletedTask; + }; + var authentication = BuiltInGameProviderAuthentications.CreateXai(options); + Uri? opened = null; + string? notice = null; + + var credential = await authentication.LoginAsync( + "oauth-xai-device-code", + new GameAuthInteraction + { + NotifyAsync = (message, _) => + { + notice = message; + return default; + }, + OpenBrowserAsync = (uri, _) => + { + opened = uri; + return default; + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal("xai-access", credential.Secret); + Assert.Equal(new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(9) }, delays); + Assert.Contains("ABCD", notice, StringComparison.Ordinal); + Assert.Equal("accounts.x.ai", opened?.Host); + Assert.Contains("referrer=opengameagent", requests[0], StringComparison.Ordinal); + Assert.Equal(4, requests.Count); + } + + [Fact] + public async Task DeviceFlowRejectsAnUntrustedVerificationHostBeforePolling() + { + var calls = 0; + var handler = new DelegateHandler((_, _) => + { + calls++; + return Task.FromResult(Json(HttpStatusCode.OK, """ + {"device_code":"device","user_code":"ABCD","verification_uri":"https://attacker.example/device","expires_in":900,"interval":5} + """)); + }); + var authentication = BuiltInGameProviderAuthentications.CreateXai( + Options(handler, new InMemoryGameCredentialStore())); + + await Assert.ThrowsAsync(async () => + await authentication.LoginAsync( + "oauth-xai-device-code", + new GameAuthInteraction(), + TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + } + + [Fact] + public async Task KimiRefreshRetriesOnlyTransientFailuresAndPreservesTheRotatedCredential() + { + var now = new DateTimeOffset(2026, 8, 8, 0, 0, 0, TimeSpan.Zero); + var store = new InMemoryGameCredentialStore(); + await store.SetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.KimiForCodingProviderId), + new GameCredential( + GameCredentialKind.OAuth, + "old-access", + now.AddMinutes(-1), + new Dictionary { ["refresh_token"] = "old-refresh" }), + TestContext.Current.CancellationToken); + var responses = new Queue(new[] + { + Json(HttpStatusCode.InternalServerError, "{\"error\":\"server_error\"}"), + Json(HttpStatusCode.TooManyRequests, "{\"error\":\"slow_down\"}"), + Json(HttpStatusCode.OK, "{\"access_token\":\"new-access\",\"refresh_token\":\"new-refresh\",\"expires_in\":3600}"), + }); + var handler = new DelegateHandler((_, _) => Task.FromResult(responses.Dequeue())); + var delays = new List(); + var options = Options(handler, store); + options.Clock = () => now; + options.DelayAsync = (delay, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + delays.Add(delay); + return Task.CompletedTask; + }; + var authentication = BuiltInGameProviderAuthentications.CreateKimiForCoding(options); + + var resolution = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + + Assert.Equal("new-access", resolution?.Credential?.Secret); + Assert.Equal("new-refresh", resolution?.Credential?.Metadata["refresh_token"]); + Assert.Equal(new[] { TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500) }, delays); + } + + [Fact] + public async Task CancelingDeviceLoginPreventsALateCredentialCommit() + { + var tokenRequestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseTokenResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var handler = new DelegateHandler((_, _) => + { + calls++; + if (calls == 1) + { + return Task.FromResult(Json(HttpStatusCode.OK, """ + {"device_code":"device","user_code":"ABCD","verification_uri":"https://accounts.x.ai/oauth2/device","expires_in":900,"interval":1} + """)); + } + + tokenRequestStarted.TrySetResult(true); + return releaseTokenResponse.Task; + }); + var store = new InMemoryGameCredentialStore(); + var options = Options(handler, store); + options.DelayAsync = (_, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }; + var authentication = BuiltInGameProviderAuthentications.CreateXai(options); + using var cancellation = new CancellationTokenSource(); + var login = authentication.LoginAsync( + "oauth-xai-device-code", + new GameAuthInteraction(), + cancellation.Token).AsTask(); + await tokenRequestStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => login); + releaseTokenResponse.TrySetResult(Json( + HttpStatusCode.OK, + "{\"access_token\":\"late-access\",\"refresh_token\":\"late-refresh\",\"expires_in\":3600}")); + await Task.Yield(); + + var stored = await store.GetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.XaiProviderId), + TestContext.Current.CancellationToken); + Assert.Null(stored); + } + + [Fact] + public async Task AnthropicUsesJsonExchangeAndRefreshWhileRetainingApiKeyFallback() + { + var now = new DateTimeOffset(2026, 8, 8, 0, 0, 0, TimeSpan.Zero); + var bodies = new List(); + var responses = new Queue(new[] + { + Json(HttpStatusCode.OK, "{\"access_token\":\"access\",\"refresh_token\":\"refresh\",\"expires_in\":3600}"), + Json(HttpStatusCode.OK, "{\"access_token\":\"next\",\"refresh_token\":\"next-refresh\",\"expires_in\":3600}"), + }); + var handler = new DelegateHandler(async (request, cancellationToken) => + { + bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + return responses.Dequeue(); + }); + var store = new InMemoryGameCredentialStore(); + var options = Options(handler, store); + options.Clock = () => now; + var authentication = BuiltInGameProviderAuthentications.CreateAnthropic(options); + Uri? authorization = null; + var credential = await authentication.LoginAsync( + "oauth-anthropic-subscription", + new GameAuthInteraction + { + OpenBrowserAsync = (uri, _) => + { + authorization = uri; + return default; + }, + PromptAsync = (_, _, _) => + { + var query = ParseQuery(authorization!.Query); + return new ValueTask( + "http://localhost:53692/callback?code=authorization-code&state=" + + Uri.EscapeDataString(query["state"])); + }, + }, + TestContext.Current.CancellationToken); + Assert.Equal("access", credential.Secret); + + await store.SetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.AnthropicProviderId), + new GameCredential( + GameCredentialKind.OAuth, + credential.Secret, + now.AddMinutes(-1), + credential.Metadata), + TestContext.Current.CancellationToken); + var refreshed = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + + Assert.Equal("next", refreshed?.Credential?.Secret); + Assert.Equal("oauth-2025-04-20", refreshed?.Headers["anthropic-beta"]); + using var exchange = JsonDocument.Parse(bodies[0]); + Assert.Equal("authorization_code", exchange.RootElement.GetProperty("grant_type").GetString()); + Assert.Equal( + ParseQuery(authorization!.Query)["state"], + exchange.RootElement.GetProperty("state").GetString()); + using var refresh = JsonDocument.Parse(bodies[1]); + Assert.Equal("refresh_token", refresh.RootElement.GetProperty("grant_type").GetString()); + } + + [Fact] + public async Task OpenAICodexDeviceFlowPollsImmediatelyAndStoresTheAccountIdentifier() + { + var accountToken = JwtWithAccountId("account-123"); + var requests = new List<(Uri Uri, string Body)>(); + var responses = new Queue(new[] + { + Json(HttpStatusCode.OK, """ + {"device_auth_id":"device-auth","user_code":"ABCD-EFGH","interval":"1"} + """), + Json(HttpStatusCode.BadRequest, """ + {"error":{"code":"deviceauth_authorization_pending"}} + """), + Json(HttpStatusCode.OK, """ + {"authorization_code":"authorization-code","code_verifier":"device-verifier"} + """), + Json(HttpStatusCode.OK, $$""" + {"access_token":"{{accountToken}}","refresh_token":"refresh-token","expires_in":3600} + """), + }); + var handler = new DelegateHandler(async (request, cancellationToken) => + { + requests.Add((request.RequestUri!, await request.Content!.ReadAsStringAsync(cancellationToken))); + return responses.Dequeue(); + }); + var delays = new List(); + var store = new InMemoryGameCredentialStore(); + var options = Options(handler, store); + options.DelayAsync = (delay, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + delays.Add(delay); + return Task.CompletedTask; + }; + var authentication = BuiltInGameProviderAuthentications.CreateOpenAICodex(options); + Uri? opened = null; + + var credential = await authentication.LoginAsync( + "oauth-openai-codex-device-code", + new GameAuthInteraction + { + OpenBrowserAsync = (uri, _) => + { + opened = uri; + return default; + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal(accountToken, credential.Secret); + Assert.Equal("account-123", credential.Metadata["openai-codex.account-id"]); + Assert.Equal(new[] { TimeSpan.FromSeconds(1) }, delays); + Assert.Equal("https://auth.openai.com/codex/device", opened?.AbsoluteUri); + Assert.Equal(4, requests.Count); + Assert.Equal("/api/accounts/deviceauth/usercode", requests[0].Uri.AbsolutePath); + Assert.Equal("/api/accounts/deviceauth/token", requests[1].Uri.AbsolutePath); + Assert.Equal("/api/accounts/deviceauth/token", requests[2].Uri.AbsolutePath); + Assert.Equal("/oauth/token", requests[3].Uri.AbsolutePath); + var exchange = ParseQuery(requests[3].Body); + Assert.Equal("authorization_code", exchange["grant_type"]); + Assert.Equal("authorization-code", exchange["code"]); + Assert.Equal("device-verifier", exchange["code_verifier"]); + Assert.Equal("https://auth.openai.com/deviceauth/callback", exchange["redirect_uri"]); + var stored = await store.GetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.OpenAICodexProviderId), + TestContext.Current.CancellationToken); + Assert.Equal("account-123", stored?.Metadata["openai-codex.account-id"]); + } + + [Fact] + public void RegistrationAddsOnlySupportedDirectoryProvidersAndPreservesExplicitOverrides() + { + using var client = new HttpClient(new DelegateHandler((_, _) => + Task.FromResult(Json(HttpStatusCode.OK, "{}")))); + var runtimeOptions = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = _ => null, + }; + var explicitAuthentication = new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "explicit-key")); + runtimeOptions.Authentications.Add( + BuiltInGameProviderAuthentications.AnthropicProviderId, + explicitAuthentication); + var authenticationOptions = new BuiltInGameOAuthOptions( + client, + new InMemoryGameCredentialStore()); + + var registered = runtimeOptions.RegisterBuiltInOAuth(authenticationOptions); + + Assert.Equal(3, registered); + Assert.Same( + explicitAuthentication, + runtimeOptions.Authentications[BuiltInGameProviderAuthentications.AnthropicProviderId]); + Assert.Contains(BuiltInGameProviderAuthentications.OpenRouterProviderId, runtimeOptions.Authentications.Keys); + Assert.Contains(BuiltInGameProviderAuthentications.XaiProviderId, runtimeOptions.Authentications.Keys); + Assert.Contains(BuiltInGameProviderAuthentications.KimiForCodingProviderId, runtimeOptions.Authentications.Keys); + Assert.DoesNotContain(BuiltInGameProviderAuthentications.OpenAICodexProviderId, runtimeOptions.Authentications.Keys); + Assert.Equal(5, BuiltInGameOAuthRegistration.SupportedProviderIds.Count); + + var runtime = new BuiltInGameModelRuntime(runtimeOptions); + foreach (var pair in runtimeOptions.Authentications) + { + Assert.Same(pair.Value, runtime.Catalog.GetProvider(pair.Key)?.Authentication); + } + } + + [Fact] + public async Task RegisteredCodexAuthenticationCarriesStoredAccountMetadataToTheWire() + { + var token = JwtWithAccountId("account-through-runtime"); + var store = new InMemoryGameCredentialStore(); + await store.SetAsync( + new GameCredentialKey(BuiltInGameProviderAuthentications.OpenAICodexProviderId), + new GameCredential( + GameCredentialKind.OAuth, + token, + DateTimeOffset.UtcNow.AddHours(1), + new Dictionary + { + ["refresh_token"] = "refresh-token", + ["openai-codex.account-id"] = "account-through-runtime", + }), + TestContext.Current.CancellationToken); + var handler = new CodexRecordingHandler(); + using var client = new HttpClient(handler); + var runtimeOptions = new BuiltInGameModelRuntimeOptions(client) + { + Directory = CodexDirectory(), + GetEnvironmentVariable = _ => null, + }; + + Assert.Equal(1, runtimeOptions.RegisterBuiltInOAuth(new BuiltInGameOAuthOptions(client, store) + { + OpenAICodexClientId = "test-codex-client", + })); + var runtime = new BuiltInGameModelRuntime(runtimeOptions); + var terminal = await runtime.CompleteAsync( + BuiltInGameProviderAuthentications.OpenAICodexProviderId, + new ModelRequest( + "gpt-codex", + "system", + Array.Empty(), + Array.Empty(), + new ModelParameters(), + "session", + "run", + 1), + TestContext.Current.CancellationToken); + + Assert.Null(terminal.ErrorMessage); + Assert.Equal("Bearer " + token, handler.Header("Authorization")); + Assert.Equal("account-through-runtime", handler.Header("chatgpt-account-id")); + Assert.Equal("opengameagent", handler.Header("originator")); + } + + [Fact] + public async Task MissingProviderClientIdsExposeNoOAuthSchemesAndNeverReachTheNetwork() + { + var calls = 0; + var handler = new DelegateHandler((_, _) => + { + calls++; + return Task.FromResult(Json(HttpStatusCode.OK, "{}")); + }); + var options = new BuiltInGameOAuthOptions( + new HttpClient(handler), + new InMemoryGameCredentialStore()); + var authentications = new[] + { + BuiltInGameProviderAuthentications.CreateAnthropic(options), + BuiltInGameProviderAuthentications.CreateXai(options), + BuiltInGameProviderAuthentications.CreateKimiForCoding(options), + BuiltInGameProviderAuthentications.CreateOpenAICodex(options), + }; + + foreach (var authentication in authentications) + { + Assert.Empty(authentication.Schemes); + var error = await Assert.ThrowsAsync(async () => + await authentication.LoginAsync( + "oauth-unconfigured", + new GameAuthInteraction(), + TestContext.Current.CancellationToken)); + Assert.Contains("OAuth client ID", error.Message, StringComparison.Ordinal); + } + + Assert.Equal(0, calls); + } + + private static BuiltInGameOAuthOptions Options( + HttpMessageHandler handler, + IGameCredentialStore store) => + new(new HttpClient(handler), store) + { + LoginTimeout = TimeSpan.FromSeconds(20), + RequestTimeout = TimeSpan.FromSeconds(5), + AnthropicClientId = "test-anthropic-client", + XaiClientId = "test-xai-client", + KimiForCodingClientId = "test-kimi-client", + OpenAICodexClientId = "test-codex-client", + }; + + private static async Task SendRawCallbackAsync( + Uri redirect, + string host, + string query) + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, redirect.Port); + var request = Encoding.ASCII.GetBytes( + $"GET {redirect.AbsolutePath}{query} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + await client.GetStream().WriteAsync(request); + using var reader = new StreamReader(client.GetStream(), Encoding.ASCII); + var statusLine = await reader.ReadLineAsync(); + return (HttpStatusCode)int.Parse(statusLine!.Split(' ')[1], System.Globalization.CultureInfo.InvariantCulture); + } + + private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary fields) => + new UriBuilder(endpoint) + { + Query = string.Join("&", fields.Select(pair => + Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))), + }.Uri; + + private static Dictionary ParseQuery(string query) => + query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2)) + .ToDictionary( + part => Uri.UnescapeDataString(part[0]), + part => Uri.UnescapeDataString(part.Length == 2 ? part[1] : string.Empty), + StringComparer.Ordinal); + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static string JwtWithAccountId(string accountId) + { + var header = Base64Url(Encoding.UTF8.GetBytes("{\"alg\":\"none\"}")); + var payload = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new Dictionary + { + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_account_id"] = accountId, + }, + })); + return header + "." + payload + ".signature"; + } + + private static GameModelDirectorySnapshot CodexDirectory() => GameModelDirectory.ParseJson(""" + { + "version": "test", + "generatedAt": "2026-08-08T00:00:00Z", + "providers": [{ + "id": "openai-codex", + "name": "OpenAI Codex", + "endpoint": "https://chatgpt.com/backend-api/codex", + "models": [{ + "id": "gpt-codex", + "name": "GPT Codex", + "api": "openai-codex-responses", + "contextWindow": 8192, + "maximumOutput": 512, + "input": ["text"], + "output": ["text", "tools"] + }] + }] + } + """); + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private sealed class DelegateHandler : HttpMessageHandler + { + private readonly Func> _send; + + public DelegateHandler(Func> send) + { + _send = send; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + _send(request, cancellationToken); + } + + private sealed class CodexRecordingHandler : HttpMessageHandler + { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + public string? Header(string name) => _headers.TryGetValue(name, out var value) ? value : null; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + foreach (var header in request.Headers) + { + _headers[header.Key] = string.Join(",", header.Value); + } + + if (request.Content is not null) + { + _ = await request.Content.ReadAsStringAsync(cancellationToken); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-codex\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + Encoding.UTF8, + "text/event-stream"), + }; + } + } +} diff --git a/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/OpenGameAgent.Models.Auth.BuiltIn.Tests.csproj b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/OpenGameAgent.Models.Auth.BuiltIn.Tests.csproj new file mode 100644 index 0000000..54a2161 --- /dev/null +++ b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/OpenGameAgent.Models.Auth.BuiltIn.Tests.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0 + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json new file mode 100644 index 0000000..bfea949 --- /dev/null +++ b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json @@ -0,0 +1,349 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==" + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models.auth.builtin": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Models": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models.BuiltIn": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models.builtin": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Models": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openaicompatible": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs b/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs new file mode 100644 index 0000000..19211f5 --- /dev/null +++ b/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs @@ -0,0 +1,2203 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Amazon.BedrockRuntime.Model; +using OpenGameAgent.Kernel; +using OpenGameAgent.Providers.Bedrock; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Models.BuiltIn.Tests; + +public sealed class BuiltInGameModelRuntimeTests +{ + public static TheoryData HttpApis => new() + { + { + BuiltInGameModelApis.OpenAiResponses, + "openai", + "https://catalog.invalid/openai/v1", + "/custom/responses", + "authorization" + }, + { + BuiltInGameModelApis.OpenAiCompletions, + "compatible", + "https://catalog.invalid/compatible/v1", + "/compatible/v1/chat/completions", + "x-model-token" + }, + { + BuiltInGameModelApis.AnthropicMessages, + "anthropic", + "https://catalog.invalid/anthropic/v1", + "/anthropic/v1/messages", + "x-api-key" + }, + { + BuiltInGameModelApis.GoogleGenerativeAi, + "google", + "https://catalog.invalid/google/v1beta", + "/google/v1beta/models/model:streamGenerateContent", + "x-goog-api-key" + }, + { + BuiltInGameModelApis.GoogleVertex, + "google-vertex", + "https://catalog.invalid/vertex/v1/projects/p/locations/l/publishers/google", + "/vertex/v1/projects/p/locations/l/publishers/google/models/model:streamGenerateContent", + "authorization" + }, + { + BuiltInGameModelApis.MistralConversations, + "mistral", + "https://catalog.invalid/mistral/v1", + "/mistral/v1/chat/completions", + "authorization" + }, + }; + + public static TheoryData OpenAiCompatibleFamilies => new() + { + { "zai", "glm-5.2", "max_tokens", "system", "zai", false, true, false }, + { "deepseek", "deepseek-v4-flash", "max_completion_tokens", "system", "deepseek", false, true, false }, + { "moonshotai", "kimi-k2.5", "max_tokens", "system", "deepseek-toggle", false, true, false }, + { "togetherai", "deepseek-ai/DeepSeek-V4-Pro", "max_tokens", "system", "together", false, false, false }, + { "nvidia", "minimaxai/minimax-m3", "max_tokens", "system", "none", false, false, false }, + { "cloudflare-workers-ai", "@cf/openai/gpt-oss-20b", "max_completion_tokens", "system", "effort", false, false, true }, + { "openrouter", "openai/gpt-5.4", "max_completion_tokens", "developer", "openrouter", true, true, false }, + { "fireworks-ai", "accounts/fireworks/models/glm-5p2", "max_completion_tokens", "system", "effort", false, false, true }, + { "baseten", "zai-org/GLM-5.2", "max_tokens", "system", "baseten", false, false, false }, + }; + + [Fact] + public void BundledDirectoryRegistersEveryProviderIntoTheSharedCatalog() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = _ => null, + }; + + var runtime = new BuiltInGameModelRuntime(options); + + Assert.Equal(runtime.Directory.Providers.Count, runtime.Catalog.GetProviders().Count); + Assert.Equal(runtime.Directory.Models.Count, runtime.Catalog.GetModels().Count); + var bundledApis = runtime.Catalog.GetModels() + .Select(model => model.Api) + .Distinct(StringComparer.Ordinal) + .ToArray(); + Assert.All(bundledApis, api => Assert.Contains(api, BuiltInGameModelRuntime.SupportedApis)); + Assert.Contains(BuiltInGameModelApis.AzureOpenAiResponses, BuiltInGameModelRuntime.SupportedApis); + Assert.Contains(BuiltInGameModelApis.OpenAiCodexResponses, BuiltInGameModelRuntime.SupportedApis); + Assert.DoesNotContain(BuiltInGameModelApis.AzureOpenAiResponses, bundledApis); + Assert.DoesNotContain(BuiltInGameModelApis.OpenAiCodexResponses, bundledApis); + Assert.Equal(9, BuiltInGameModelRuntime.SupportedApis.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public async Task AzureDirectoryAuthenticationAndConfigurationReachTheResponsesWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.AzureOpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "azure-custom", + BuiltInGameModelApis.AzureOpenAiResponses, + "https://directory.openai.azure.com/openai", + modelId: "logical-model")); + ProviderResponseObservation? observed = null; + options.ResponseObserver = (value, _) => + { + observed = value; + return default; + }; + options.Authentications.Add( + "azure-custom", + new FixedAuthentication(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.ApiKey, "azure-secret"), + "resolved", + new Uri("https://resolved.openai.azure.com/openai"), + new Dictionary { ["X-Resolved"] = "yes" }, + new Dictionary + { + [BuiltInGameModelConfigurationKeys.AzureApiVersion] = "2025-04-01-preview", + [BuiltInGameModelConfigurationKeys.AzureDeploymentName] = "deployment-one", + }))); + var runtime = new BuiltInGameModelRuntime(options); + + var descriptor = runtime.Catalog.GetModel("azure-custom", "logical-model"); + Assert.NotNull(descriptor); + Assert.Equal(BuiltInGameModelApis.AzureOpenAiResponses, descriptor!.Api); + var events = await CollectAsync(runtime.StreamAsync( + "azure-custom", + Request(model: "logical-model"), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("resolved.openai.azure.com", handler.RequestUri!.Host); + Assert.Equal("/openai/v1/responses", handler.RequestUri.AbsolutePath); + Assert.Contains("api-version=2025-04-01-preview", handler.RequestUri.Query, StringComparison.Ordinal); + Assert.Equal("azure-secret", handler.Header("api-key")); + Assert.Equal("yes", handler.Header("X-Resolved")); + Assert.Contains("\"model\":\"deployment-one\"", handler.Body, StringComparison.Ordinal); + Assert.Equal(BuiltInGameModelApis.AzureOpenAiResponses, observed!.ApiId); + } + + [Fact] + public async Task AzureDefaultEnvironmentMapsKeyEndpointVersionAndDeployment() + { + var handler = new RecordingHandler(BuiltInGameModelApis.AzureOpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "azure-env", + BuiltInGameModelApis.AzureOpenAiResponses, + "https://directory.openai.azure.com/openai", + modelId: "logical-model")); + options.GetEnvironmentVariable = name => name switch + { + "AZURE_OPENAI_API_KEY" => "environment-secret", + "AZURE_OPENAI_BASE_URL" => "https://environment.openai.azure.com/openai", + "AZURE_OPENAI_API_VERSION" => "2026-01-01-preview", + "AZURE_OPENAI_DEPLOYMENT_NAME" => "environment-deployment", + _ => null, + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "azure-env", + Request(model: "logical-model"), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("environment.openai.azure.com", handler.RequestUri!.Host); + Assert.Contains("api-version=2026-01-01-preview", handler.RequestUri.Query, StringComparison.Ordinal); + Assert.Equal("environment-secret", handler.Header("api-key")); + Assert.Contains("\"model\":\"environment-deployment\"", handler.Body, StringComparison.Ordinal); + } + + [Fact] + public async Task MissingAzureKeyIsAnInBandFailure() + { + var handler = new RecordingHandler(BuiltInGameModelApis.AzureOpenAiResponses); + using var client = new HttpClient(handler); + var runtime = new BuiltInGameModelRuntime(Options( + client, + Directory( + "azure-missing", + BuiltInGameModelApis.AzureOpenAiResponses, + "https://missing.openai.azure.com/openai"))); + + var events = await CollectAsync(runtime.StreamAsync( + "azure-missing", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("credential", failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + + [Theory] + [InlineData(BuiltInGameModelConfigurationKeys.AzureApiVersion, "bad version", "API version")] + [InlineData(BuiltInGameModelConfigurationKeys.AzureDeploymentName, "bad deployment", "deployment")] + public async Task InvalidAzureRequestConfigurationIsAnInBandFailure( + string key, + string value, + string expectedError) + { + var handler = new RecordingHandler(BuiltInGameModelApis.AzureOpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "azure-invalid", + BuiltInGameModelApis.AzureOpenAiResponses, + "https://invalid.openai.azure.com/openai")); + options.Authentications.Add( + "azure-invalid", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "azure-secret"))); + var configuration = new GameModelProviderTransportConfiguration(); + configuration.Options[key] = value; + options.ProviderConfigurations.Add("azure-invalid", configuration); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "azure-invalid", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains(expectedError, failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + + [Fact] + public async Task CodexOAuthResolutionAndAccountConfigurationReachTheResponsesWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCodexResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "codex-custom", + BuiltInGameModelApis.OpenAiCodexResponses, + "https://directory.invalid/backend-api/codex", + modelId: "gpt-codex")); + ProviderResponseObservation? observed = null; + options.ResponseObserver = (value, _) => + { + observed = value; + return default; + }; + options.Authentications.Add( + "codex-custom", + new FixedAuthentication(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.OAuth, CodexToken("embedded-account")), + "oauth", + new Uri("https://resolved.invalid/backend-api/codex"), + new Dictionary { ["X-Resolved"] = "yes" }, + new Dictionary + { + [BuiltInGameModelConfigurationKeys.OpenAiCodexAccountId] = "configured-account", + }))); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "codex-custom", + Request(model: "gpt-codex"), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("resolved.invalid", handler.RequestUri!.Host); + Assert.Equal("/backend-api/codex/responses", handler.RequestUri.AbsolutePath); + Assert.Equal("Bearer " + CodexToken("embedded-account"), handler.Header("Authorization")); + Assert.Equal("configured-account", handler.Header("chatgpt-account-id")); + Assert.Equal("yes", handler.Header("X-Resolved")); + Assert.Equal("opengameagent", handler.Header("originator")); + Assert.Contains("\"model\":\"gpt-codex\"", handler.Body, StringComparison.Ordinal); + Assert.Equal(BuiltInGameModelApis.OpenAiCodexResponses, observed!.ApiId); + } + + [Fact] + public async Task CodexRejectsApiKeysAsAnInBandFailure() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCodexResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "codex-key", + BuiltInGameModelApis.OpenAiCodexResponses, + "https://codex.invalid/backend-api/codex")); + options.Authentications.Add( + "codex-key", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, CodexToken("account")))); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "codex-key", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("API keys are not accepted", failure.Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Null(handler.RequestUri); + } + + [Fact] + public async Task CodexEnvironmentCredentialRequiresExplicitOptIn() + { + var token = CodexToken("environment-account"); + var implicitHandler = new RecordingHandler(BuiltInGameModelApis.OpenAiCodexResponses); + using var implicitClient = new HttpClient(implicitHandler); + var implicitReads = new List(); + var implicitOptions = Options( + implicitClient, + Directory( + "codex-environment", + BuiltInGameModelApis.OpenAiCodexResponses, + "https://codex.invalid/backend-api/codex", + environmentVariables: "OPENAI_API_KEY,CODEX_ACCESS_TOKEN")); + implicitOptions.GetEnvironmentVariable = name => + { + implicitReads.Add(name); + return name is "OPENAI_API_KEY" or "CODEX_ACCESS_TOKEN" ? token : null; + }; + var implicitRuntime = new BuiltInGameModelRuntime(implicitOptions); + + var implicitEvents = await CollectAsync(implicitRuntime.StreamAsync( + "codex-environment", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(implicitEvents, item => item.IsTerminal).Kind); + Assert.Empty(implicitReads); + Assert.Null(implicitHandler.RequestUri); + + var explicitHandler = new RecordingHandler(BuiltInGameModelApis.OpenAiCodexResponses); + using var explicitClient = new HttpClient(explicitHandler); + var explicitOptions = Options( + explicitClient, + Directory( + "codex-environment", + BuiltInGameModelApis.OpenAiCodexResponses, + "https://codex.invalid/backend-api/codex")); + explicitOptions.GetEnvironmentVariable = name => name == "CODEX_ACCESS_TOKEN" ? token : null; + var configuration = new GameModelProviderTransportConfiguration(); + configuration.Options[BuiltInGameModelConfigurationKeys.OpenAiCodexEnvironmentVariable] = + "CODEX_ACCESS_TOKEN"; + explicitOptions.ProviderConfigurations.Add("codex-environment", configuration); + var explicitRuntime = new BuiltInGameModelRuntime(explicitOptions); + + var explicitEvents = await CollectAsync(explicitRuntime.StreamAsync( + "codex-environment", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(explicitEvents, item => item.IsTerminal).Kind); + Assert.Equal("Bearer " + token, explicitHandler.Header("Authorization")); + } + + [Fact] + public void CatalogDescriptorsExposeOnlyMediaCapabilitiesImplementedByTextProviders() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var runtime = new BuiltInGameModelRuntime(new BuiltInGameModelRuntimeOptions(client) + { + Directory = MediaCapabilityDirectory(), + GetEnvironmentVariable = _ => null, + }); + var rawInput = Assert.Single(runtime.Directory.Models); + var executableInput = runtime.Catalog.GetModel(rawInput.ProviderId, rawInput.ModelId)!; + Assert.False(executableInput.InputCapabilities.HasFlag(GameModelInputCapabilities.Audio)); + Assert.False(executableInput.InputCapabilities.HasFlag(GameModelInputCapabilities.Video)); + Assert.Throws(() => runtime.Catalog.Resolve( + rawInput.ProviderId, + rawInput.ModelId, + requiredInput: GameModelInputCapabilities.Audio)); + + Assert.Equal( + GameModelOutputCapabilities.None, + executableInput.OutputCapabilities + & (GameModelOutputCapabilities.Image + | GameModelOutputCapabilities.Audio + | GameModelOutputCapabilities.Video)); + Assert.All(runtime.Catalog.GetModels(), model => + { + Assert.Equal( + GameModelInputCapabilities.None, + model.InputCapabilities + & ~(GameModelInputCapabilities.Text + | GameModelInputCapabilities.Image + | GameModelInputCapabilities.StructuredData)); + Assert.Equal( + GameModelOutputCapabilities.None, + model.OutputCapabilities + & ~(GameModelOutputCapabilities.Text + | GameModelOutputCapabilities.StructuredData + | GameModelOutputCapabilities.ToolCalls + | GameModelOutputCapabilities.Reasoning)); + }); + } + + [Fact] + public async Task BundledAudioCapabilityIsDowngradedBeforeGoogleWireSerialization() + { + var handler = new RecordingHandler(BuiltInGameModelApis.GoogleGenerativeAi); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = name => name == "GOOGLE_API_KEY" ? "google-key" : null, + }; + var runtime = new BuiltInGameModelRuntime(options); + var raw = runtime.Directory.GetModels("google").First(); + var request = new ModelRequest( + raw.ModelId, + "system", + new[] + { + new AgentMessage( + AgentRole.User, + new AgentContent[] + { + new TextContent("listen"), + new BinaryContent(AgentMediaKind.Audio, "YXVkaW8=", "audio/wav"), + }, + DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters(), + "session", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "google", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Contains("[audio omitted: model does not support this input]", handler.Body, StringComparison.Ordinal); + Assert.DoesNotContain("YXVkaW8=", handler.Body, StringComparison.Ordinal); + } + + [Fact] + public async Task MixedApiProviderBuildsOneProviderScopedEnvironmentCredentialChain() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options(client, MixedApiDirectory()); + options.GetEnvironmentVariable = name => name == "OPENAI_API_KEY" ? "mixed-key" : null; + + var runtime = new BuiltInGameModelRuntime(options); + + var available = await runtime.Catalog.GetAvailableModelsAsync( + "mixed", + TestContext.Current.CancellationToken); + Assert.Equal(2, available.Count); + var authentication = runtime.Catalog.GetProvider("mixed")!.Authentication; + var resolution = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + Assert.NotNull(resolution); + Assert.Equal(GameCredentialKind.ApiKey, resolution!.Credential!.Kind); + Assert.Equal("mixed-key", resolution.Credential.Secret); + } + + [Fact] + public async Task DirectoryDeclaredNonstandardEnvironmentCredentialPrecedesApiFallback() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "huggingface", + BuiltInGameModelApis.OpenAiCompletions, + "https://huggingface.invalid/v1", + "HF_TOKEN,HF_TOKEN,HUGGINGFACE_API_KEY")); + var reads = new List(); + options.GetEnvironmentVariable = name => + { + reads.Add(name); + return name == "HF_TOKEN" ? "hf-secret" : null; + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "huggingface", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("Bearer hf-secret", handler.Header("Authorization")); + Assert.Equal(2, reads.Count); + Assert.All(reads, name => Assert.Equal("HF_TOKEN", name)); + } + + [Fact] + public void InvalidDirectoryEnvironmentVariableMetadataIsRejected() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions)); + var options = Options( + client, + Directory( + "compatible", + BuiltInGameModelApis.OpenAiCompletions, + "https://compatible.invalid/v1", + "VALID_API_KEY,BAD-NAME")); + + var error = Assert.Throws(() => new BuiltInGameModelRuntime(options)); + + Assert.Contains("environment variable metadata", error.Message, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(HttpApis))] + public async Task DirectoryConfigurationAndApiDispatchReachEachHttpProvider( + string api, + string providerId, + string directoryEndpoint, + string expectedPath, + string authenticationHeader) + { + var handler = new RecordingHandler(api); + using var client = new HttpClient(handler); + var options = Options(client, Directory(providerId, api, directoryEndpoint)); + ProviderResponseObservation? observed = null; + options.ResponseObserver = (value, _) => + { + observed = value; + return default; + }; + var credentialKind = api == BuiltInGameModelApis.GoogleVertex + ? GameCredentialKind.BearerToken + : GameCredentialKind.ApiKey; + var authentication = new StaticGameProviderAuthentication( + credential: new GameCredential(credentialKind, "test-credential")); + options.Authentications.Add(providerId, authentication); + var configuration = new GameModelProviderTransportConfiguration(); + configuration.Headers["X-Configuration"] = "runtime"; + if (api == BuiltInGameModelApis.OpenAiResponses) + { + configuration.BaseUrl = new Uri("https://override.invalid/custom"); + } + + if (api == BuiltInGameModelApis.OpenAiCompletions) + { + configuration.Options[BuiltInGameModelConfigurationKeys.AuthenticationHeader] = "X-Model-Token"; + configuration.Options[BuiltInGameModelConfigurationKeys.AuthenticationScheme] = "Token"; + } + + options.ProviderConfigurations.Add(providerId, configuration); + var runtime = new BuiltInGameModelRuntime(options); + var registration = runtime.Catalog.GetProvider(providerId); + + Assert.NotNull(registration); + Assert.Same(authentication, registration!.Authentication); + var events = await CollectAsync(runtime.Catalog.Resolve(providerId, "model").Provider.StreamAsync( + Request(temperature: 0.75, maxOutputTokens: 4096), + TestContext.Current.CancellationToken)); + + var terminal = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(ModelStreamEventKind.Completed, terminal.Kind); + Assert.NotNull(handler.RequestUri); + Assert.Equal(expectedPath, handler.RequestUri!.AbsolutePath); + if (api == BuiltInGameModelApis.OpenAiResponses) + { + Assert.Equal("override.invalid", handler.RequestUri.Host); + } + else + { + Assert.Equal("catalog.invalid", handler.RequestUri.Host); + } + + Assert.Equal("catalog", handler.Header("X-Directory")); + Assert.Equal("runtime", handler.Header("X-Configuration")); + Assert.NotNull(handler.Header(authenticationHeader)); + Assert.Contains("test-credential", handler.Header(authenticationHeader), StringComparison.Ordinal); + Assert.NotNull(observed); + Assert.Equal(api, observed!.ApiId); + Assert.Equal(providerId, observed.ProviderId); + if (api == BuiltInGameModelApis.OpenAiCompletions) + { + Assert.Equal("Token test-credential", handler.Header(authenticationHeader)); + } + + Assert.Contains("\"directory_marker\":\"applied\"", handler.Body, StringComparison.Ordinal); + Assert.DoesNotContain("\"temperature\"", handler.Body, StringComparison.Ordinal); + Assert.Contains("512", handler.Body, StringComparison.Ordinal); + } + + [Fact] + public async Task VertexApplicationDefaultCredentialIsResolvedLazilyInsideTheProviderRequest() + { + var handler = new RecordingHandler(BuiltInGameModelApis.GoogleVertex); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("google-vertex", BuiltInGameModelApis.GoogleVertex, "https://vertex.invalid/v1")); + options.GetEnvironmentVariable = name => name switch + { + "GOOGLE_CLOUD_PROJECT" => "project", + "GOOGLE_CLOUD_LOCATION" => "location", + _ => null, + }; + var calls = 0; + options.VertexApplicationDefaultCredential = cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + calls++; + return new ValueTask("adc-token"); + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "google-vertex", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal(1, calls); + Assert.Equal("Bearer adc-token", handler.Header("Authorization")); + } + + [Fact] + public async Task VertexExplicitApiKeyUsesGoogleApiKeyAuthentication() + { + var handler = new RecordingHandler(BuiltInGameModelApis.GoogleVertex); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("google-vertex", BuiltInGameModelApis.GoogleVertex, "https://vertex.invalid/v1")); + options.Authentications.Add( + "google-vertex", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "vertex-api-key"))); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "google-vertex", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("vertex-api-key", handler.Header("x-goog-api-key")); + Assert.Null(handler.Header("Authorization")); + } + + [Theory] + [InlineData("http://remote.invalid/v1", false, false)] + [InlineData("http://remote.invalid/v1", true, true)] + [InlineData("http://127.0.0.1:12345/v1", false, true)] + public async Task InsecureHttpRequiresExplicitOptInExceptForLoopback( + string endpoint, + bool allowInsecureHttp, + bool shouldComplete) + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, endpoint)); + options.AllowInsecureHttp = allowInsecureHttp; + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "key"))); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + var terminal = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(shouldComplete ? ModelStreamEventKind.Completed : ModelStreamEventKind.Failed, terminal.Kind); + if (shouldComplete) + { + Assert.Equal(Uri.UriSchemeHttp, handler.RequestUri!.Scheme); + } + else + { + Assert.Contains("HTTPS", terminal.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + } + + [Fact] + public async Task BundledCloudflareGatewayExpandsEnvironmentEndpointAndUsesGatewayAuthHeader() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = name => name switch + { + "CLOUDFLARE_API_TOKEN" => "cloudflare-token", + "CLOUDFLARE_ACCOUNT_ID" => "account", + "CLOUDFLARE_GATEWAY_ID" => "gateway", + _ => null, + }, + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "cloudflare-ai-gateway", + Request(model: "anthropic/claude-3-5-haiku"), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("gateway.ai.cloudflare.com", handler.RequestUri!.Host); + Assert.Equal("/v1/account/gateway/compat/chat/completions", handler.RequestUri.AbsolutePath); + Assert.Equal("Bearer cloudflare-token", handler.Header("cf-aig-authorization")); + Assert.Null(handler.Header("Authorization")); + } + + [Fact] + public async Task DirectoryConfigurationAndApiDispatchReachBedrockProvider() + { + ConverseStreamRequest? captured = null; + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory( + "amazon-bedrock", + BuiltInGameModelApis.BedrockConverseStream, + "https://bedrock.invalid")); + var configuration = new GameModelProviderTransportConfiguration + { + BedrockTransport = (request, cancellationToken) => Capture(request, cancellationToken), + }; + configuration.Options[BuiltInGameModelConfigurationKeys.AwsRegion] = "us-east-1"; + options.ProviderConfigurations.Add("amazon-bedrock", configuration); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "amazon-bedrock", + Request(temperature: 0.75, maxOutputTokens: 4096), + TestContext.Current.CancellationToken)); + + var terminal = Assert.Single(events, item => item.IsTerminal); + Assert.True( + terminal.Kind == ModelStreamEventKind.Completed, + terminal.Response?.ErrorMessage ?? "The provider did not complete."); + Assert.NotNull(captured); + Assert.Equal("model", captured!.ModelId); + Assert.Equal(512, captured.InferenceConfig.MaxTokens); + Assert.Null(captured.InferenceConfig.Temperature); + var fields = captured.AdditionalModelRequestFields.AsDictionary(); + Assert.Equal("applied", fields["directory_marker"].AsString()); + + async IAsyncEnumerable Capture( + ConverseStreamRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + captured = request; + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.MessageStop("end_turn"); + yield return BedrockProtocolEvent.Usage(1, 1); + } + } + + [Fact] + public async Task BedrockRemoteHttpServiceUrlRequiresExplicitOptIn() + { + var calls = 0; + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory( + "amazon-bedrock", + BuiltInGameModelApis.BedrockConverseStream, + "http://bedrock.invalid")); + var configuration = new GameModelProviderTransportConfiguration + { + BedrockTransport = Transport, + }; + options.ProviderConfigurations.Add("amazon-bedrock", configuration); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "amazon-bedrock", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("HTTPS", failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, calls); + + async IAsyncEnumerable Transport( + ConverseStreamRequest _, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + calls++; + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + } + } + + [Fact] + public async Task UnsupportedUserAndToolImagesBecomeStableTextBeforeProviderSerialization() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("compatible", BuiltInGameModelApis.OpenAiCompletions, "https://catalog.invalid/v1")); + options.Authentications.Add( + "compatible", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "key"))); + var runtime = new BuiltInGameModelRuntime(options); + var call = new ToolCallContent("call-1", "inspect", "{}"); + var messages = new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse, + provider: "compatible", + api: BuiltInGameModelApis.OpenAiCompletions), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] + { + new BinaryContent(AgentMediaKind.Image, "dG9vbA==", "image/png"), + }), + DateTimeOffset.UnixEpoch), + }; + var request = new ModelRequest( + "model", + string.Empty, + messages, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "compatible", + request, + TestContext.Current.CancellationToken)); + + var terminal = Assert.Single(events, item => item.IsTerminal); + Assert.True( + terminal.Kind == ModelStreamEventKind.Completed, + terminal.Response?.ErrorMessage ?? "The provider did not complete."); + Assert.Equal(2, Occurrences(handler.Body, "[image omitted: model does not support this input]")); + Assert.DoesNotContain("aW1hZ2U=", handler.Body, StringComparison.Ordinal); + Assert.DoesNotContain("dG9vbA==", handler.Body, StringComparison.Ordinal); + } + + [Fact] + public async Task MissingConfigurationIsAnInBandTerminalFailure() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication(configured: false)); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("not configured", failure.Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task UnknownApiIsAnInBandTerminalFailure() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("custom", "unknown-wire-api", "https://catalog.invalid/v1")); + options.Authentications.Add("custom", new StaticGameProviderAuthentication()); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "custom", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("unsupported API", failure.Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task RealtimeOnlyModelsRemainInspectableButAreNotExecutableThroughResponsesHttp() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory( + "openai", + BuiltInGameModelApis.OpenAiResponses, + "https://openai.invalid/v1", + modelId: "gpt-realtime-2.1")); + options.Authentications.Add("openai", new StaticGameProviderAuthentication()); + var runtime = new BuiltInGameModelRuntime(options); + + Assert.Single(runtime.Directory.GetModels("openai")); + Assert.Null(runtime.Catalog.GetModel("openai", "gpt-realtime-2.1")); + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(model: "gpt-realtime-2.1"), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("no longer registered", failure.Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task DeferredResolverFailureDoesNotEscapeAsyncSetup() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add("openai", new StaticGameProviderAuthentication()); + options.ResolveConfigurationAsync = async (_, cancellationToken) => + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException("resolver failed after await"); + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Equal("resolver failed after await", failure.Response!.ErrorMessage); + } + + [Theory] + [InlineData("header-name")] + [InlineData("header-nul")] + [InlineData("header-length")] + [InlineData("option-key")] + [InlineData("option-nul")] + public async Task InvalidRequestConfigurationBecomesAnInBandTerminalFailure(string invalidField) + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add("openai", new StaticGameProviderAuthentication()); + options.ResolveConfigurationAsync = (_, _) => + { + var configuration = new GameModelProviderTransportConfiguration(); + switch (invalidField) + { + case "header-name": + configuration.Headers["Bad Header"] = "value"; + break; + case "header-nul": + configuration.Headers["X-Test"] = "value\0"; + break; + case "header-length": + configuration.Headers["X-Test"] = new string('x', 65_537); + break; + case "option-key": + configuration.Options[new string('k', 257)] = "value"; + break; + case "option-nul": + configuration.Options["test.option"] = "value\0"; + break; + default: + throw new InvalidOperationException("Unknown test case."); + } + + return new ValueTask(configuration); + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("invalid", failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + + [Fact] + public async Task AuthStaticAndRequestConfigurationMergeByFieldWithRequestWinning() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://directory.invalid/v1")); + options.Authentications.Add( + "openai", + new FixedAuthentication(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.ApiKey, "auth-credential"), + "test-auth", + new Uri("https://auth.invalid/v1"), + new Dictionary + { + ["X-Order"] = "auth", + ["Authorization"] = "Bearer auth-header", + }))); + var staticConfiguration = new GameModelProviderTransportConfiguration + { + BaseUrl = new Uri("https://static.invalid/v1"), + }; + staticConfiguration.Headers["x-order"] = "static"; + options.ProviderConfigurations.Add("openai", staticConfiguration); + options.ResolveConfigurationAsync = async (_, cancellationToken) => + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + var requestConfiguration = new GameModelProviderTransportConfiguration + { + BaseUrl = new Uri("https://request.invalid/v1"), + }; + requestConfiguration.Headers["X-ORDER"] = "request"; + requestConfiguration.Headers["authorization"] = "Bearer request-header"; + return requestConfiguration; + }; + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal("request.invalid", handler.RequestUri!.Host); + Assert.Equal("request", handler.Header("X-Order")); + Assert.Equal("Bearer request-header", handler.Header("Authorization")); + } + + [Fact] + public async Task RequestConfigurationCanDeleteHeadersFromEarlierLayers() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "openai", + BuiltInGameModelApis.OpenAiResponses, + "https://directory.invalid/v1", + modelHeaders: new Dictionary + { + ["X-Delete"] = "model", + ["X-Model"] = "kept", + })); + options.Authentications.Add( + "openai", + new FixedAuthentication(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.ApiKey, "secret-never-added-to-the-request"), + "test-auth", + headers: new Dictionary + { + ["X-Delete"] = "authentication", + ["X-Authentication"] = "kept", + }))); + var providerConfiguration = new GameModelProviderTransportConfiguration(); + providerConfiguration.Headers["X-Delete"] = "provider"; + providerConfiguration.Headers["X-Provider"] = "kept"; + options.ProviderConfigurations.Add("openai", providerConfiguration); + ModelRequest? requestSeenByResolver = null; + options.ResolveConfigurationAsync = (context, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + requestSeenByResolver = context.Request; + var requestConfiguration = new GameModelProviderTransportConfiguration(); + requestConfiguration.Headers["x-delete"] = null; + requestConfiguration.Headers["X-Authentication"] = null; + return new ValueTask(requestConfiguration); + }; + var request = Request(); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Same(request, requestSeenByResolver); + Assert.Null(handler.Header("X-Delete")); + Assert.Null(handler.Header("X-Authentication")); + Assert.Equal("kept", handler.Header("X-Model")); + Assert.Equal("kept", handler.Header("X-Provider")); + Assert.DoesNotContain("secret-never-added-to-the-request", request.SystemPrompt, StringComparison.Ordinal); + } + + [Theory] + [InlineData(BuiltInGameModelApis.OpenAiResponses, "Host", null)] + [InlineData(BuiltInGameModelApis.OpenAiResponses, "Content-Length", "1")] + [InlineData(BuiltInGameModelApis.BedrockConverseStream, "Authorization", null)] + [InlineData(BuiltInGameModelApis.BedrockConverseStream, "x-amz-security-token", "attacker")] + public async Task TransportControlledHeadersCannotBeConfiguredOrDeleted( + string api, + string header, + string? value) + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var providerId = api == BuiltInGameModelApis.BedrockConverseStream ? "amazon-bedrock" : "openai"; + var options = Options(client, Directory(providerId, api, "https://provider.invalid/v1")); + if (api == BuiltInGameModelApis.OpenAiResponses) + { + options.Authentications.Add( + providerId, + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + } + + var configuration = new GameModelProviderTransportConfiguration(); + configuration.Headers[header] = value; + configuration.Options[BuiltInGameModelConfigurationKeys.AwsSkipAuthentication] = "true"; + configuration.Options[BuiltInGameModelConfigurationKeys.AwsRegion] = "us-east-1"; + options.ProviderConfigurations.Add(providerId, configuration); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + providerId, + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("header", failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + + [Theory] + [InlineData(BuiltInGameModelApis.OpenAiResponses, "openai", "Host")] + [InlineData(BuiltInGameModelApis.OpenAiCompletions, "compatible", "Content-Length")] + public async Task ConfigurableCredentialHeaderCannotTargetTransportControlledHeader( + string api, + string providerId, + string header) + { + var handler = new RecordingHandler(api); + using var client = new HttpClient(handler); + var options = Options(client, Directory(providerId, api, "https://provider.invalid/v1")); + options.Authentications.Add( + providerId, + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "secret"))); + var configuration = new GameModelProviderTransportConfiguration(); + configuration.Options[BuiltInGameModelConfigurationKeys.AuthenticationHeader] = header; + options.ProviderConfigurations.Add(providerId, configuration); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + providerId, + Request(), + TestContext.Current.CancellationToken)); + + var failure = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Contains("transport", failure.Response!.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(handler.RequestUri); + } + + [Fact] + public async Task StoredAuthenticationFlowsThroughCatalogIntoProviderRequest() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var store = new InMemoryGameCredentialStore(); + await store.SetAsync( + new GameCredentialKey("openai"), + new GameCredential(GameCredentialKind.ApiKey, "stored-credential"), + TestContext.Current.CancellationToken); + var authentication = new StoredGameProviderAuthentication("openai", store); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add("openai", authentication); + var runtime = new BuiltInGameModelRuntime(options); + + var available = await runtime.Catalog.GetAvailableModelsAsync( + "openai", + TestContext.Current.CancellationToken); + var response = await runtime.CompleteAsync( + "openai", + Request(), + TestContext.Current.CancellationToken); + + Assert.Single(available); + Assert.Equal(ModelStopReason.Stop, response.StopReason); + Assert.Contains("stored-credential", handler.Header("Authorization"), StringComparison.Ordinal); + } + + [Fact] + public async Task CancellationInterruptsNonCooperativeAuthenticationInsteadOfBecomingFailureEvent() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add("openai", new NonCooperativeAuthentication()); + var runtime = new BuiltInGameModelRuntime(options); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var _ in runtime.StreamAsync("openai", Request(), cancellation.Token)) + { + } + }); + } + + [Fact] + public async Task CancellationInterruptsNonCooperativeRequestConfigurationResolver() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + options.ResolveConfigurationAsync = (_, _) => new ValueTask(pending.Task); + var runtime = new BuiltInGameModelRuntime(options); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var _ in runtime.StreamAsync("openai", Request(), cancellation.Token)) + { + } + }); + + pending.TrySetException(new InvalidOperationException("late resolver failure")); + } + + [Fact] + public async Task RawProviderPreservesStructuredFailureWhileRuntimeStreamProvidesInBandBoundary() + { + var handler = new RecordingHandler( + BuiltInGameModelApis.OpenAiResponses, + HttpStatusCode.TooManyRequests, + "{\"error\":{\"code\":\"insufficient_quota\"}}", + response => response.Headers.TryAddWithoutValidation("retry-after", "10")); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(runtime.CreateProvider("openai").StreamAsync( + Request(), + TestContext.Current.CancellationToken))); + var boundary = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(429, exception.StatusCode); + Assert.False(exception.IsTransient); + Assert.Equal(TimeSpan.FromSeconds(10), exception.RetryAfter); + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(boundary).Kind); + } + + [Fact] + public async Task ConsumerStoppingAtTerminalDisposesTheInnerProviderStream() + { + var provider = new TrackingProvider(); + var runtime = RuntimeWithProvider(provider); + + await foreach (var streamEvent in runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)) + { + if (streamEvent.IsTerminal) + { + break; + } + } + + Assert.Equal(1, provider.DisposeCount); + } + + [Fact] + public async Task ConsumerStoppingBeforeTerminalDisposesTheInnerProviderStream() + { + var provider = new TrackingProvider(); + var runtime = RuntimeWithProvider(provider); + + await foreach (var _ in runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)) + { + break; + } + + Assert.Equal(1, provider.DisposeCount); + } + + [Fact] + public async Task HostileInnerDisposeCannotReplaceTerminalOrAddAnotherFailure() + { + var provider = new TrackingProvider(throwOnDispose: true); + var runtime = RuntimeWithProvider(provider); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + Request(), + TestContext.Current.CancellationToken)); + + Assert.Equal(2, events.Count); + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.Equal(1, provider.DisposeCount); + } + + [Fact] + public async Task UnknownProviderAndModelAreInBandTerminalFailures() + { + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1")); + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "key"))); + var runtime = new BuiltInGameModelRuntime(options); + + var unknownProvider = await CollectAsync(runtime.StreamAsync( + "missing", + Request(), + TestContext.Current.CancellationToken)); + var unknownModel = await CollectAsync(runtime.StreamAsync( + "openai", + Request(model: "missing"), + TestContext.Current.CancellationToken)); + + Assert.Contains("no longer registered", Assert.Single(unknownProvider).Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Contains("no longer registered", Assert.Single(unknownModel).Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Theory] + [MemberData(nameof(OpenAiCompatibleFamilies))] + public async Task BundledOpenAiCompatibleCompatibilityReachesTheWire( + string providerId, + string modelId, + string maximumTokenField, + string systemRole, + string reasoningShape, + bool sendsStore, + bool supportsLongCache, + bool sendsSessionAffinity) + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = name => name switch + { + "CLOUDFLARE_ACCOUNT_ID" => "account", + "CLOUDFLARE_GATEWAY_ID" => "gateway", + _ => null, + }, + }; + options.Authentications.Add( + providerId, + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + var selection = runtime.Catalog.Resolve( + providerId, + modelId, + reasoning: GameReasoningLevel.High); + var parameters = selection.CreateParameters(new ModelParameters + { + MaxOutputTokens = 123, + CacheRetention = ModelCacheRetention.Long, + }); + var request = new ModelRequest( + modelId, + "rules", + Array.Empty(), + new[] { new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}") }, + parameters, + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + providerId, + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + using var document = JsonDocument.Parse(handler.Body); + var root = document.RootElement; + Assert.Equal(123, root.GetProperty(maximumTokenField).GetInt32()); + Assert.False(root.TryGetProperty( + maximumTokenField == "max_tokens" ? "max_completion_tokens" : "max_tokens", + out _)); + Assert.Equal(systemRole, root.GetProperty("messages")[0].GetProperty("role").GetString()); + if (sendsStore) + { + Assert.False(root.GetProperty("store").GetBoolean()); + } + else + { + Assert.False(root.TryGetProperty("store", out _)); + } + + if (supportsLongCache) + { + Assert.Equal("24h", root.GetProperty("prompt_cache_retention").GetString()); + } + else + { + Assert.False(root.TryGetProperty("prompt_cache_retention", out _)); + } + + switch (reasoningShape) + { + case "zai": + Assert.Equal("enabled", root.GetProperty("thinking").GetProperty("type").GetString()); + Assert.False(root.GetProperty("thinking").GetProperty("clear_thinking").GetBoolean()); + Assert.True(root.GetProperty("tool_stream").GetBoolean()); + break; + case "deepseek": + Assert.Equal("enabled", root.GetProperty("thinking").GetProperty("type").GetString()); + Assert.Equal("high", root.GetProperty("reasoning_effort").GetString()); + break; + case "deepseek-toggle": + Assert.Equal("enabled", root.GetProperty("thinking").GetProperty("type").GetString()); + Assert.False(root.TryGetProperty("reasoning_effort", out _)); + break; + case "together": + Assert.True(root.GetProperty("reasoning").GetProperty("enabled").GetBoolean()); + Assert.Equal("high", root.GetProperty("reasoning_effort").GetString()); + break; + case "openrouter": + Assert.Equal("high", root.GetProperty("reasoning").GetProperty("effort").GetString()); + Assert.False(root.TryGetProperty("reasoning_effort", out _)); + break; + case "effort": + Assert.Equal("high", root.GetProperty("reasoning_effort").GetString()); + break; + case "baseten": + Assert.True(root.GetProperty("chat_template_args").GetProperty("enable_thinking").GetBoolean()); + Assert.Equal("high", root.GetProperty("reasoning_effort").GetString()); + break; + case "none": + Assert.False(root.TryGetProperty("thinking", out _)); + Assert.False(root.TryGetProperty("reasoning", out _)); + Assert.False(root.TryGetProperty("reasoning_effort", out _)); + break; + default: + throw new InvalidOperationException("Unknown reasoning fixture."); + } + + Assert.Equal(sendsSessionAffinity ? "session-1" : null, handler.Header("x-session-affinity")); + if (providerId == "nvidia") + { + Assert.Equal("3600", handler.Header("NVCF-POLL-SECONDS")); + } + + Assert.DoesNotContain("${", handler.RequestUri!.OriginalString, StringComparison.Ordinal); + } + + [Fact] + public async Task BundledXaiResponsesCompatibilityReachesTheWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = _ => null, + }; + options.Authentications.Add( + "xai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + var selection = runtime.Catalog.Resolve( + "xai", + "grok-4.5", + reasoning: GameReasoningLevel.High); + var parameters = selection.CreateParameters(new ModelParameters + { + MaxOutputTokens = 123, + CacheRetention = ModelCacheRetention.Long, + }); + var request = new ModelRequest( + "grok-4.5", + "rules", + Array.Empty(), + new[] { new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}") }, + parameters, + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "xai", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.EndsWith("/responses", handler.RequestUri!.AbsolutePath, StringComparison.Ordinal); + using var document = JsonDocument.Parse(handler.Body); + var root = document.RootElement; + Assert.Equal(123, root.GetProperty("max_output_tokens").GetInt32()); + Assert.Equal("high", root.GetProperty("reasoning").GetProperty("effort").GetString()); + Assert.Equal("developer", root.GetProperty("input")[0].GetProperty("role").GetString()); + Assert.False(root.GetProperty("tools")[0].TryGetProperty("strict", out _)); + Assert.False(root.TryGetProperty("prompt_cache_retention", out _)); + } + + [Fact] + public async Task BundledAnthropicAdaptiveStrictAndCacheCompatibilityReachesTheWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.AnthropicMessages); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = _ => null, + }; + options.Authentications.Add( + "anthropic", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + var selection = runtime.Catalog.Resolve( + "anthropic", + "claude-opus-4-6", + reasoning: GameReasoningLevel.High); + var parameters = selection.CreateParameters(new ModelParameters + { + CacheRetention = ModelCacheRetention.Long, + }); + var strict = new ToolDefinition( + "inspect", + "Inspect", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}}}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var request = new ModelRequest( + "claude-opus-4-6", + "rules", + Array.Empty(), + new[] { strict }, + parameters, + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "anthropic", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + using var document = JsonDocument.Parse(handler.Body); + var root = document.RootElement; + Assert.Equal("adaptive", root.GetProperty("thinking").GetProperty("type").GetString()); + Assert.Equal("high", root.GetProperty("output_config").GetProperty("effort").GetString()); + Assert.Equal("1h", root.GetProperty("system")[0].GetProperty("cache_control").GetProperty("ttl").GetString()); + Assert.True(root.GetProperty("tools")[0].GetProperty("strict").GetBoolean()); + } + + [Fact] + public async Task BundledOpenAiStrictGrammarDeferredAndExplicitCacheCompatibilityReachesTheWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiResponses); + using var client = new HttpClient(handler); + var options = new BuiltInGameModelRuntimeOptions(client) + { + GetEnvironmentVariable = _ => null, + }; + options.Authentications.Add( + "openai", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + var selection = runtime.Catalog.Resolve( + "openai", + "gpt-5.6-sol", + reasoning: GameReasoningLevel.High); + var parameters = selection.CreateParameters(new ModelParameters + { + CacheRetention = ModelCacheRetention.None, + }); + var inspect = new ToolDefinition( + "inspect", + "Inspect", + "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}", + ToolConstrainedSampling.Grammar(openAiRegex: "[a-z]+")); + var move = new ToolDefinition( + "move", + "Move", + "{\"type\":\"object\"}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var call = new ToolCallContent("call_inspect|fc_inspect", "inspect", "{\"value\":\"x\"}"); + var messages = new AgentMessage[] + { + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "gpt-5.6-sol", + stopReason: ModelStopReason.ToolUse, + provider: "openai", + api: BuiltInGameModelApis.OpenAiResponses), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] { new TextContent("ok") }, addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch), + }; + var request = new ModelRequest( + "gpt-5.6-sol", + "rules", + messages, + new[] { inspect, move }, + parameters, + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "openai", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + using var document = JsonDocument.Parse(handler.Body); + var root = document.RootElement; + Assert.Equal("explicit", root.GetProperty("prompt_cache_options").GetProperty("mode").GetString()); + Assert.Equal("custom", root.GetProperty("tools")[0].GetProperty("type").GetString()); + var additional = Assert.Single(root.GetProperty("input").EnumerateArray(), item => + item.TryGetProperty("type", out var type) && type.GetString() == "additional_tools"); + Assert.True(additional.GetProperty("tools")[0].GetProperty("strict").GetBoolean()); + Assert.Equal("high", root.GetProperty("reasoning").GetProperty("effort").GetString()); + } + + [Fact] + public async Task GoogleLegacyToolSchemaCompatibilityReachesTheWire() + { + var handler = new RecordingHandler(BuiltInGameModelApis.GoogleGenerativeAi); + using var client = new HttpClient(handler); + var options = Options( + client, + Directory( + "google", + BuiltInGameModelApis.GoogleGenerativeAi, + "https://google.invalid/v1beta", + compatibility: new Dictionary + { + ["useLegacyOpenApiToolSchemas"] = true, + })); + options.Authentications.Add( + "google", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "test-key"))); + var runtime = new BuiltInGameModelRuntime(options); + var tool = new ToolDefinition( + "inspect", + "Inspect", + "{\"$schema\":\"draft\",\"type\":\"object\",\"properties\":{\"path\":{\"$id\":\"nested\",\"type\":\"string\"}}}"); + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + new[] { tool }, + new ModelParameters(), + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "google", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + using var document = JsonDocument.Parse(handler.Body); + var declaration = document.RootElement.GetProperty("tools")[0] + .GetProperty("functionDeclarations")[0]; + Assert.True(declaration.TryGetProperty("parameters", out var parameters)); + Assert.False(declaration.TryGetProperty("parametersJsonSchema", out _)); + Assert.False(parameters.TryGetProperty("$schema", out _)); + Assert.False(parameters.GetProperty("properties").GetProperty("path").TryGetProperty("$id", out _)); + } + + [Fact] + public async Task BedrockStrictToolCompatibilityReachesTheWire() + { + ConverseStreamRequest? captured = null; + using var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var options = Options( + client, + Directory( + "amazon-bedrock", + BuiltInGameModelApis.BedrockConverseStream, + "https://bedrock.invalid", + compatibility: new Dictionary + { + ["supportsStrictMode"] = true, + })); + var transport = new GameModelProviderTransportConfiguration + { + BedrockTransport = Capture, + }; + transport.Options[BuiltInGameModelConfigurationKeys.AwsRegion] = "us-east-1"; + options.ProviderConfigurations.Add("amazon-bedrock", transport); + var runtime = new BuiltInGameModelRuntime(options); + var strict = new ToolDefinition( + "inspect", + "Inspect", + "{\"type\":\"object\"}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + new[] { strict }, + new ModelParameters(), + "session-1", + "run", + 1); + + var events = await CollectAsync(runtime.StreamAsync( + "amazon-bedrock", + request, + TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events, item => item.IsTerminal).Kind); + Assert.True(Assert.Single(captured!.ToolConfig.Tools).ToolSpec.Strict); + + async IAsyncEnumerable Capture( + ConverseStreamRequest value, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + captured = value; + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.MessageStop("end_turn"); + yield return BedrockProtocolEvent.Usage(1, 1); + } + } + + private static BuiltInGameModelRuntimeOptions Options( + HttpClient client, + GameModelDirectorySnapshot directory) => + new(client) + { + Directory = directory, + GetEnvironmentVariable = _ => null, + }; + + private static BuiltInGameModelRuntime RuntimeWithProvider(IModelProvider provider) + { + var client = new HttpClient(new RecordingHandler(BuiltInGameModelApis.OpenAiResponses)); + var runtime = new BuiltInGameModelRuntime(Options( + client, + Directory("openai", BuiltInGameModelApis.OpenAiResponses, "https://catalog.invalid/v1"))); + var current = runtime.Catalog.GetProvider("openai")!; + runtime.Catalog.Register( + new GameModelProviderRegistration( + current.Descriptor, + provider, + new StaticGameProviderAuthentication(configured: true, source: "test"), + current.Models, + catalogVersion: current.CatalogVersion), + replace: true); + return runtime; + } + + private static GameModelDirectorySnapshot Directory( + string providerId, + string api, + string endpoint, + string? environmentVariables = null, + string modelId = "model", + Dictionary? compatibility = null, + Dictionary? modelHeaders = null) + { + var json = JsonSerializer.Serialize(new + { + version = "test", + generatedAt = "2026-08-08T00:00:00Z", + providers = new[] + { + new + { + id = providerId, + name = providerId, + endpoint, + metadata = environmentVariables is null + ? new Dictionary() + : new Dictionary + { + [BuiltInGameModelConfigurationKeys.EnvironmentVariablesMetadata] = environmentVariables, + }, + models = new[] + { + new + { + id = modelId, + name = "Model", + api, + contextWindow = 8192, + maximumOutput = 512, + input = new[] { "text" }, + output = new[] { "text", "tools" }, + sampling = new Dictionary + { + ["directory_marker"] = "applied", + }, + headers = modelHeaders ?? new Dictionary + { + ["X-Directory"] = "catalog", + }, + compatibility = compatibility ?? new Dictionary + { + ["supportsTemperature"] = false, + ["structuredOutput"] = true, + ["interleaved"] = api == BuiltInGameModelApis.OpenAiCompletions + ? new Dictionary { ["field"] = "reasoning_details" } + : false, + }, + }, + }, + }, + }, + }); + return GameModelDirectory.ParseJson(json); + } + + private static GameModelDirectorySnapshot MixedApiDirectory() + { + var json = JsonSerializer.Serialize(new + { + version = "test", + generatedAt = "2026-08-08T00:00:00Z", + providers = new[] + { + new + { + id = "mixed", + name = "Mixed", + endpoint = "https://mixed.invalid/v1", + models = new[] + { + new + { + id = "responses-model", + name = "Responses", + api = BuiltInGameModelApis.OpenAiResponses, + contextWindow = 8192, + maximumOutput = 512, + input = new[] { "text" }, + output = new[] { "text", "tools" }, + }, + new + { + id = "completions-model", + name = "Completions", + api = BuiltInGameModelApis.OpenAiCompletions, + contextWindow = 8192, + maximumOutput = 512, + input = new[] { "text" }, + output = new[] { "text", "tools" }, + }, + }, + }, + }, + }); + return GameModelDirectory.ParseJson(json); + } + + private static GameModelDirectorySnapshot MediaCapabilityDirectory() + { + const string json = """ + { + "version": "test", + "generatedAt": "2026-08-08T00:00:00Z", + "providers": [{ + "id": "media", + "name": "Media", + "endpoint": "https://media.invalid/v1", + "models": [{ + "id": "media-model", + "name": "Media Model", + "api": "openai-completions", + "contextWindow": 8192, + "maximumOutput": 512, + "input": ["text", "image", "audio", "video", "structured"], + "output": ["text", "image", "audio", "video", "structured", "tools"] + }] + }] + } + """; + return GameModelDirectory.ParseJson(json); + } + + private static ModelRequest Request( + string model = "model", + double? temperature = null, + int? maxOutputTokens = null) => + new( + model, + "system", + Array.Empty(), + Array.Empty(), + new ModelParameters + { + Temperature = temperature, + MaxOutputTokens = maxOutputTokens, + }, + "session", + "run", + 1); + + private static async Task> CollectAsync( + IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var streamEvent in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + events.Add(streamEvent); + } + + return events; + } + + private static int Occurrences(string value, string expected) + { + var count = 0; + var offset = 0; + while ((offset = value.IndexOf(expected, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += expected.Length; + } + + return count; + } + + private static string CodexToken(string accountId) + { + static string Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + return Encode("{\"alg\":\"none\"}") + + "." + + Encode(JsonSerializer.Serialize(new Dictionary + { + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_account_id"] = accountId, + }, + })) + + ".signature"; + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly string _api; + private readonly HttpStatusCode _status; + private readonly string? _responseBody; + private readonly Action? _configureResponse; + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + public RecordingHandler( + string api, + HttpStatusCode status = HttpStatusCode.OK, + string? responseBody = null, + Action? configureResponse = null) + { + _api = api; + _status = status; + _responseBody = responseBody; + _configureResponse = configureResponse; + } + + public Uri? RequestUri { get; private set; } + + public string Body { get; private set; } = string.Empty; + + public string? Header(string name) => _headers.TryGetValue(name, out var value) ? value : null; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + Body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + foreach (var header in request.Headers) + { + _headers[header.Key] = string.Join(",", header.Value); + } + + var response = new HttpResponseMessage(_status) + { + Content = new StringContent(_responseBody ?? ResponseBody(_api), Encoding.UTF8, "text/event-stream"), + }; + _configureResponse?.Invoke(response); + return response; + } + + private static string ResponseBody(string api) => api switch + { + BuiltInGameModelApis.AzureOpenAiResponses + or BuiltInGameModelApis.OpenAiCodexResponses + or BuiltInGameModelApis.OpenAiResponses => + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"model\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + BuiltInGameModelApis.OpenAiCompletions => + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + BuiltInGameModelApis.AnthropicMessages => """ + event: message_start + data: {"type":"message_start","message":{"id":"msg_1","model":"model","usage":{"input_tokens":0,"output_tokens":0}}} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":0}} + + event: message_stop + data: {"type":"message_stop"} + + """, + BuiltInGameModelApis.GoogleGenerativeAi or BuiltInGameModelApis.GoogleVertex => + "data: {\"responseId\":\"response-1\",\"candidates\":[{\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2}}\n\n", + BuiltInGameModelApis.MistralConversations => + "data: {\"id\":\"response-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n", + _ => throw new InvalidOperationException("No test response is defined for API '" + api + "'."), + }; + } + + private sealed class FixedAuthentication : IGameProviderAuthentication + { + private readonly GameProviderAuthResolution _resolution; + + public FixedAuthentication(GameProviderAuthResolution resolution) + { + _resolution = resolution; + } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(new GameProviderAuthStatus( + true, + _resolution.Source, + _resolution.Credential?.Kind)); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_resolution); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("The fixed test authentication cannot log in."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("The fixed test authentication cannot log out."); + } + + private sealed class NonCooperativeAuthentication : IGameProviderAuthentication + { + private readonly TaskCompletionSource _pending = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) => + new(_pending.Task); + + public ValueTask ResolveAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Resolution must not run while the check is pending."); + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("The non-cooperative test authentication cannot log in."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("The non-cooperative test authentication cannot log out."); + } + + private sealed class TrackingProvider : IModelProvider + { + private readonly bool _throwOnDispose; + private int _disposeCount; + + public TrackingProvider(bool throwOnDispose = false) + { + _throwOnDispose = throwOnDispose; + } + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) => + new TrackingStream(this, cancellationToken); + + private sealed class TrackingStream : IAsyncEnumerable, IAsyncEnumerator + { + private readonly TrackingProvider _owner; + private readonly CancellationToken _cancellationToken; + private int _index; + + public TrackingStream(TrackingProvider owner, CancellationToken cancellationToken) + { + _owner = owner; + _cancellationToken = cancellationToken; + } + + public ModelStreamEvent Current => _index switch + { + 1 => ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)), + 2 => ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("done") }, + ModelStopReason.Stop)), + _ => throw new InvalidOperationException("The stream has no current event."), + }; + + public IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default) => this; + + public ValueTask MoveNextAsync() + { + _cancellationToken.ThrowIfCancellationRequested(); + _index++; + return new ValueTask(_index <= 2); + } + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _owner._disposeCount); + return _owner._throwOnDispose + ? ValueTask.FromException(new InvalidOperationException("hostile dispose")) + : ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/OpenGameAgent.Models.BuiltIn.Tests/OpenGameAgent.Models.BuiltIn.Tests.csproj b/tests/OpenGameAgent.Models.BuiltIn.Tests/OpenGameAgent.Models.BuiltIn.Tests.csproj new file mode 100644 index 0000000..c4003a4 --- /dev/null +++ b/tests/OpenGameAgent.Models.BuiltIn.Tests/OpenGameAgent.Models.BuiltIn.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Models.BuiltIn.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json b/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json new file mode 100644 index 0000000..fc73535 --- /dev/null +++ b/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json @@ -0,0 +1,342 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==" + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models.builtin": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Models": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )", + "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openaicompatible": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs b/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs new file mode 100644 index 0000000..1df0965 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs @@ -0,0 +1,331 @@ +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.Providers.Anthropic; +using OpenGameAgent.Providers.Bedrock; +using OpenGameAgent.Providers.Google; +using OpenGameAgent.Providers.Mistral; +using OpenGameAgent.Providers.OpenAI; +using OpenGameAgent.Providers.OpenAICompatible; +using Xunit; + +namespace OpenGameAgent.Models.Tests; + +public sealed class GameModelDirectoryTests +{ + [Fact] + public void BundledDirectoryLoadsOfflineWithRichDescriptors() + { + var directory = GameModelDirectory.LoadBundled(); + + Assert.True(directory.Providers.Count >= 20); + Assert.True(directory.Models.Count >= 500); + Assert.NotNull(directory.GetProvider("openai")); + Assert.NotNull(directory.GetProvider("anthropic")); + Assert.NotNull(directory.GetProvider("google")); + Assert.NotNull(directory.GetProvider("deepseek")); + + var reasoningModel = directory.GetModels("openai").First(model => + model.OutputCapabilities.HasFlag(GameModelOutputCapabilities.Reasoning) + && model.InputCapabilities.HasFlag(GameModelInputCapabilities.Image) + && model.ContextWindowTokens > 0); + Assert.Contains(GameReasoningLevel.High, reasoningModel.ReasoningLevels); + Assert.True(reasoningModel.Cost.OutputPerMillionTokens >= 0); + Assert.NotNull(reasoningModel.CompatibilityJson); + } + + [Fact] + public void BundledDirectoryReusesItsImmutableParsedSnapshot() + { + var first = GameModelDirectory.LoadBundled(); + var second = GameModelDirectory.LoadBundled(); + + Assert.Same(first, second); + } + + [Fact] + public void UnknownProviderReturnsAnEmptyList() + { + var directory = GameModelDirectory.LoadBundled(); + + Assert.Empty(directory.GetModels("not-configured")); + Assert.Null(directory.GetProvider("not-configured")); + } + + [Fact] + public void BundledDirectoryApisMatchExecutableProviderCapabilities() + { + using var httpClient = new HttpClient(); + var executableProviders = new IModelProviderCapabilities[] + { + new AnthropicMessagesProvider(new AnthropicMessagesProviderOptions( + httpClient, + new Uri("https://api.anthropic.com/v1/messages"))), + new BedrockConverseProvider(new BedrockConverseProviderOptions()), + new GoogleGenerativeProvider(new GoogleGenerativeProviderOptions( + httpClient, + new Uri("https://generativelanguage.googleapis.com/v1beta"))), + new GoogleGenerativeProvider(new GoogleGenerativeProviderOptions( + httpClient, + new Uri("https://aiplatform.googleapis.com/v1"), + GoogleApiFlavor.Vertex)), + new MistralConversationsProvider(new MistralConversationsProviderOptions( + httpClient, + new Uri("https://api.mistral.ai/v1/conversations"))), + new OpenAIResponsesProvider(new OpenAIResponsesProviderOptions( + httpClient, + new Uri("https://api.openai.com/v1/responses"))), + new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + httpClient, + new Uri("https://example.invalid/v1/chat/completions"))), + }; + var executableApis = executableProviders + .SelectMany(provider => provider.SupportedApis) + .ToHashSet(StringComparer.Ordinal); + var directory = GameModelDirectory.LoadBundled(); + foreach (var model in directory.Models) + { + Assert.True( + executableApis.Contains(model.Api), + $"Bundled model '{model.ProviderId}/{model.ModelId}' references non-executable API '{model.Api}'."); + } + + var directoryApis = directory.Models + .Select(model => model.Api) + .ToHashSet(StringComparer.Ordinal); + + Assert.Equal( + executableApis.OrderBy(api => api, StringComparer.Ordinal), + directoryApis.OrderBy(api => api, StringComparer.Ordinal)); + } + + [Fact] + public void BundledVertexDirectoryContainsOnlyGeminiProtocolModels() + { + var directory = GameModelDirectory.LoadBundled(); + + var models = directory.GetModels("google-vertex"); + + Assert.NotEmpty(models); + Assert.All(models, model => Assert.StartsWith("gemini-", model.ModelId, StringComparison.Ordinal)); + Assert.DoesNotContain(models, model => model.ModelId == "gemini-3.1-flash-lite-preview"); + } + + [Fact] + public void BundledAnthropicCompatibleProvidersUseTheAnthropicProtocol() + { + var directory = GameModelDirectory.LoadBundled(); + + foreach (var providerId in new[] { "kimi-for-coding", "minimax", "minimax-cn" }) + { + var models = directory.GetModels(providerId); + Assert.NotEmpty(models); + Assert.All(models, model => Assert.Equal("anthropic-messages", model.Api)); + } + } + + [Fact] + public void BundledDirectoryAdvertisesOnlyCapabilitiesOfItsExecutableTextProtocols() + { + var directory = GameModelDirectory.LoadBundled(); + + Assert.DoesNotContain(directory.Models, model => + model.Api == "openai-responses" + && model.ModelId.Contains("realtime", StringComparison.OrdinalIgnoreCase)); + Assert.All(directory.Models, model => + { + Assert.True(model.InputCapabilities.HasFlag(GameModelInputCapabilities.Text)); + Assert.False(model.InputCapabilities.HasFlag(GameModelInputCapabilities.Audio)); + Assert.False(model.InputCapabilities.HasFlag(GameModelInputCapabilities.Video)); + Assert.True(model.OutputCapabilities.HasFlag(GameModelOutputCapabilities.Text)); + Assert.Equal( + GameModelOutputCapabilities.None, + model.OutputCapabilities + & (GameModelOutputCapabilities.Image + | GameModelOutputCapabilities.Audio + | GameModelOutputCapabilities.Video)); + }); + } + + [Fact] + public void BundledDirectoryProvidesExecutableFallbackEndpointsAndDeclaredTemplateInputs() + { + var directory = GameModelDirectory.LoadBundled(); + Assert.Equal("api.cerebras.ai", directory.GetProvider("cerebras")!.Endpoint!.Host); + Assert.Equal("api.groq.com", directory.GetProvider("groq")!.Endpoint!.Host); + Assert.Equal("api.together.ai", directory.GetProvider("togetherai")!.Endpoint!.Host); + Assert.Equal("api.x.ai", directory.GetProvider("xai")!.Endpoint!.Host); + + foreach (var provider in directory.Providers) + { + var endpoint = provider.Endpoint?.OriginalString; + if (endpoint is null || !endpoint.Contains("${", StringComparison.Ordinal)) + { + continue; + } + + Assert.True( + provider.Metadata.TryGetValue("environmentVariables", out var declared), + $"Provider '{provider.ProviderId}' has a templated endpoint without declared configuration variables."); + var variables = declared!.Split(',').Select(value => value.Trim()).ToHashSet(StringComparer.Ordinal); + foreach (System.Text.RegularExpressions.Match match in + System.Text.RegularExpressions.Regex.Matches(endpoint, @"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")) + { + Assert.Contains(match.Groups[1].Value, variables); + } + + Assert.DoesNotContain("${", System.Text.RegularExpressions.Regex.Replace( + endpoint, + @"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", + string.Empty), + StringComparison.Ordinal); + } + } + + [Fact] + public void BundledReasoningProfilesPreserveProviderVocabularyAndAlwaysThinkingModels() + { + var directory = GameModelDirectory.LoadBundled(); + + var openAi = directory.GetModels("openai").Single(model => model.ModelId == "gpt-5.4"); + Assert.Equal("none", openAi.GetReasoningValue(GameReasoningLevel.Off)); + Assert.Equal("xhigh", openAi.GetReasoningValue(GameReasoningLevel.ExtraHigh)); + Assert.DoesNotContain(GameReasoningLevel.Maximum, openAi.ReasoningLevels); + var longContextTier = Assert.Single(openAi.Cost.Tiers); + Assert.Equal(272_000, longContextTier.InputTokensAbove); + Assert.Equal(5m, longContextTier.InputPerMillionTokens); + Assert.Equal(22.5m, longContextTier.OutputPerMillionTokens); + Assert.Equal(openAi.Cost.InputPerMillionTokens, openAi.Cost.RatesForInput(272_000).InputPerMillionTokens); + Assert.Equal(longContextTier.InputPerMillionTokens, openAi.Cost.RatesForInput(272_001).InputPerMillionTokens); + + var google = directory.GetModels("google").Single(model => model.ModelId == "gemini-3.1-pro-preview"); + Assert.Equal(new[] { GameReasoningLevel.Low, GameReasoningLevel.High }, google.ReasoningLevels); + Assert.Equal("LOW", google.GetReasoningValue(GameReasoningLevel.Low)); + Assert.Equal("HIGH", google.GetReasoningValue(GameReasoningLevel.High)); + + var alwaysThinking = directory.GetModels("moonshotai") + .Single(model => model.ModelId == "kimi-k2.7-code"); + Assert.DoesNotContain(GameReasoningLevel.Off, alwaysThinking.ReasoningLevels); + Assert.Equal(GameReasoningLevel.Minimal, alwaysThinking.ClampReasoning(GameReasoningLevel.Off)); + + var zai = directory.GetModels("zai").Single(model => model.ModelId == "glm-5.2"); + Assert.Contains(GameReasoningLevel.Off, zai.ReasoningLevels); + Assert.Null(zai.GetReasoningValue(GameReasoningLevel.Off)); + Assert.Equal("high", zai.GetReasoningValue(GameReasoningLevel.Low)); + Assert.Equal("max", zai.GetReasoningValue(GameReasoningLevel.Maximum)); + + var fireworks = directory.GetModels("fireworks-ai") + .Single(model => model.ModelId == "accounts/fireworks/models/glm-5p2"); + Assert.Equal("none", fireworks.GetReasoningValue(GameReasoningLevel.Off)); + Assert.Equal("high", fireworks.GetReasoningValue(GameReasoningLevel.Medium)); + + var fable = directory.GetModels("anthropic").Single(model => model.ModelId == "claude-fable-5"); + Assert.DoesNotContain(GameReasoningLevel.Off, fable.ReasoningLevels); + Assert.Contains(GameReasoningLevel.ExtraHigh, fable.ReasoningLevels); + Assert.Contains(GameReasoningLevel.Maximum, fable.ReasoningLevels); + } + + [Fact] + public void BundledCompatibilityDeltasCoverOpenAiCompatibleAndNativeProtocolFamilies() + { + var directory = GameModelDirectory.LoadBundled(); + + AssertCompatibility("zai", "glm-5.2", "thinkingFormat", "zai"); + AssertCompatibility("deepseek", "deepseek-v4-flash", "requiresReasoningContentOnAssistantMessages", true); + AssertCompatibility("moonshotai", "kimi-k2.5", "maxTokensField", "max_tokens"); + AssertCompatibility("togetherai", "deepseek-ai/DeepSeek-V4-Pro", "thinkingFormat", "together"); + AssertCompatibility("nvidia", "minimaxai/minimax-m3", "supportsStrictMode", false); + AssertCompatibility("cloudflare-workers-ai", "@cf/openai/gpt-oss-20b", "sendSessionAffinityHeaders", true); + AssertCompatibility("openrouter", "openai/gpt-5.4", "thinkingFormat", "openrouter"); + AssertCompatibility("xai", "grok-4.5", "supportsLongCacheRetention", false); + AssertCompatibility("openai", "gpt-5.6-sol", "supportsExplicitPromptCacheMode", true); + AssertCompatibility("anthropic", "claude-opus-4-6", "forceAdaptiveThinking", true); + + var fireworksOpenAi = directory.GetModels("fireworks-ai") + .Single(model => model.ModelId == "accounts/fireworks/models/glm-5p2"); + var fireworksAnthropic = directory.GetModels("fireworks-ai") + .First(model => model.Api == "anthropic-messages"); + Assert.Equal("openai-completions", fireworksOpenAi.Api); + Assert.Equal("https://api.fireworks.ai/inference/v1", fireworksOpenAi.BaseUrl!.AbsoluteUri.TrimEnd('/')); + Assert.Equal("https://api.fireworks.ai/inference", fireworksAnthropic.BaseUrl!.AbsoluteUri.TrimEnd('/')); + + void AssertCompatibility(string provider, string modelId, string property, object expected) + { + var model = directory.GetModels(provider).Single(value => value.ModelId == modelId); + using var document = JsonDocument.Parse(model.CompatibilityJson!); + var value = document.RootElement.GetProperty(property); + if (expected is bool boolean) + { + Assert.Equal(boolean, value.GetBoolean()); + } + else + { + Assert.Equal((string)expected, value.GetString()); + } + } + } + + [Fact] + public void ParserRejectsDuplicateProviders() + { + const string json = """ + { + "version": "1", + "generatedAt": "2026-01-01T00:00:00Z", + "providers": [ + { "id": "same", "name": "One", "models": [] }, + { "id": "same", "name": "Two", "models": [] } + ] + } + """; + + var error = Assert.Throws(() => GameModelDirectory.ParseJson(json)); + Assert.Contains("duplicate provider", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ParserPreservesCapabilitiesReasoningCostsAndCompatibility() + { + const string json = """ + { + "version": "1", + "generatedAt": "2026-01-01T00:00:00Z", + "providers": [{ + "id": "local", + "name": "Local", + "endpoint": "http://127.0.0.1:1234/v1", + "local": true, + "models": [{ + "id": "model", + "name": "Model", + "api": "openai-completions", + "contextWindow": 32000, + "maximumOutput": 4096, + "input": ["text", "image", "structured"], + "output": ["text", "structured", "tools", "reasoning"], + "reasoning": ["off", "low", "high"], + "reasoningValues": { "off": "none", "low": "small", "high": "large" }, + "cost": { "input": 1.25, "output": 5.0, "cacheRead": 0.1, "cacheWrite": 0.2 }, + "headers": { "X-Kept": "value", "X-Suppressed": null }, + "compatibility": { "supportsStrictMode": true } + }] + }] + } + """; + + var directory = GameModelDirectory.ParseJson(json); + var provider = Assert.Single(directory.Providers); + var model = Assert.Single(directory.Models); + + Assert.True(provider.IsLocal); + Assert.Equal("http://127.0.0.1:1234/v1", provider.Endpoint!.AbsoluteUri.TrimEnd('/')); + Assert.Equal("openai-completions", model.Api); + Assert.Equal(GameModelInputCapabilities.Text | GameModelInputCapabilities.Image | GameModelInputCapabilities.StructuredData, model.InputCapabilities); + Assert.True(model.OutputCapabilities.HasFlag(GameModelOutputCapabilities.ToolCalls)); + Assert.Equal("none", model.GetReasoningValue(GameReasoningLevel.Off)); + Assert.Equal("small", model.GetReasoningValue(GameReasoningLevel.Low)); + Assert.Equal(5m, model.Cost.OutputPerMillionTokens); + Assert.Equal("value", model.Headers["x-kept"]); + Assert.Null(model.Headers["x-suppressed"]); + Assert.Contains("supportsStrictMode", model.CompatibilityJson, StringComparison.Ordinal); + } +} diff --git a/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs index 33cee15..c4be7ac 100644 --- a/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs +++ b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using OpenGameAgent.Extensions; using OpenGameAgent.Kernel; using Xunit; @@ -7,6 +8,25 @@ namespace OpenGameAgent.Models.Tests; public sealed class ModelCatalogTests { + [Fact] + public void ProviderAndModelEndpointsRejectNonHttpAndAmbiguousUris() + { + Assert.Throws(() => new GameProviderDescriptor( + "provider", + endpoint: new Uri("file:///tmp/provider"))); + Assert.Throws(() => new GameProviderDescriptor( + "provider", + endpoint: new Uri("https://provider.example/v1#fragment"))); + Assert.Throws(() => new GameModelDescriptor( + "provider", + "model", + baseUrl: new Uri("https://user:secret@provider.example/v1"))); + Assert.Throws(() => new GameModelDescriptor( + "provider", + "model", + baseUrl: new Uri("https://provider.example/v1#fragment"))); + } + [Fact] public void CredentialKeysExposeConsistentValueOperators() { @@ -50,6 +70,39 @@ public void DescriptorClampsReasoningAndResolutionBoundsParametersAndCost() requiredInput: GameModelInputCapabilities.Video)); } + [Fact] + public void DescriptorPreservesAlwaysThinkingAndProviderSpecificOffValues() + { + var alwaysThinking = Model( + "provider", + "always", + reasoningLevels: new[] { GameReasoningLevel.High, GameReasoningLevel.Maximum }, + reasoningLevelValues: new Dictionary + { + [GameReasoningLevel.High] = "HIGH", + [GameReasoningLevel.Maximum] = "MAXIMUM", + }); + Assert.DoesNotContain(GameReasoningLevel.Off, alwaysThinking.ReasoningLevels); + Assert.Equal(GameReasoningLevel.High, alwaysThinking.ClampReasoning(GameReasoningLevel.Off)); + Assert.Throws(() => alwaysThinking.GetReasoningValue(GameReasoningLevel.Off)); + + var switchable = Model( + "provider", + "switchable", + reasoningLevels: new[] { GameReasoningLevel.Off, GameReasoningLevel.Low }, + reasoningLevelValues: new Dictionary + { + [GameReasoningLevel.Off] = "none", + [GameReasoningLevel.Low] = "LOW", + }); + Assert.Equal("none", switchable.GetReasoningValue(GameReasoningLevel.Off)); + Assert.Equal("LOW", switchable.GetReasoningValue(GameReasoningLevel.Low)); + + var nonReasoning = new GameModelDescriptor("provider", "plain"); + Assert.Equal(new[] { GameReasoningLevel.Off }, nonReasoning.ReasoningLevels); + Assert.Null(nonReasoning.GetReasoningValue(GameReasoningLevel.Off)); + } + [Fact] public async Task RefreshOverlaysBaselineAndDetectsEveryDescriptorChange() { @@ -74,6 +127,93 @@ public async Task RefreshOverlaysBaselineAndDetectsEveryDescriptorChange() Assert.Equal(GameModelRefreshStatus.Updated, costChange.Status); } + [Fact] + public async Task RefreshStatusDetectsEveryBehaviorRelevantDescriptorField() + { + var changes = new (string Field, Func Create)[] + { + (nameof(GameModelDescriptor.ModelId), () => ComparableModel(modelId: "changed-model")), + (nameof(GameModelDescriptor.DisplayName), () => ComparableModel(displayName: "Changed model")), + (nameof(GameModelDescriptor.Api), () => ComparableModel(api: "openai-responses")), + (nameof(GameModelDescriptor.BaseUrl), () => ComparableModel(baseUrl: "https://changed.example/v1")), + (nameof(GameModelDescriptor.ContextWindowTokens), () => ComparableModel(contextWindowTokens: 120_000)), + (nameof(GameModelDescriptor.MaximumOutputTokens), () => ComparableModel(maximumOutputTokens: 12_000)), + (nameof(GameModelDescriptor.InputCapabilities), () => ComparableModel( + inputCapabilities: GameModelInputCapabilities.Text + | GameModelInputCapabilities.Image + | GameModelInputCapabilities.StructuredData)), + (nameof(GameModelDescriptor.OutputCapabilities), () => ComparableModel( + outputCapabilities: GameModelOutputCapabilities.Text + | GameModelOutputCapabilities.StructuredData + | GameModelOutputCapabilities.ToolCalls + | GameModelOutputCapabilities.Reasoning)), + (nameof(GameModelDescriptor.ReasoningLevels), () => ComparableModel( + reasoningLevels: new[] + { + GameReasoningLevel.Low, + GameReasoningLevel.Medium, + GameReasoningLevel.High, + })), + (nameof(GameModelDescriptor.ReasoningLevelValues), () => ComparableModel( + reasoningLevelValues: new Dictionary + { + [GameReasoningLevel.Low] = "changed-low", + })), + ($"{nameof(GameModelDescriptor.Cost)}.Input", () => ComparableModel(cost: ComparableCost(input: 11))), + ($"{nameof(GameModelDescriptor.Cost)}.Output", () => ComparableModel(cost: ComparableCost(output: 12))), + ($"{nameof(GameModelDescriptor.Cost)}.CacheRead", () => ComparableModel(cost: ComparableCost(cacheRead: 13))), + ($"{nameof(GameModelDescriptor.Cost)}.CacheWrite", () => ComparableModel(cost: ComparableCost(cacheWrite: 14))), + ($"{nameof(GameModelDescriptor.Cost)}.TierCount", () => ComparableModel(cost: new GameModelCost( + 1, + 2, + 3, + 4, + new[] + { + new GameModelCostTier(50_000, 5, 6, 7, 8), + new GameModelCostTier(75_000, 9, 10, 11, 12), + }))), + ($"{nameof(GameModelDescriptor.Cost)}.TierThreshold", () => ComparableModel(cost: ComparableCost(tierAbove: 60_000))), + ($"{nameof(GameModelDescriptor.Cost)}.TierInput", () => ComparableModel(cost: ComparableCost(tierInput: 15))), + ($"{nameof(GameModelDescriptor.Cost)}.TierOutput", () => ComparableModel(cost: ComparableCost(tierOutput: 16))), + ($"{nameof(GameModelDescriptor.Cost)}.TierCacheRead", () => ComparableModel(cost: ComparableCost(tierCacheRead: 17))), + ($"{nameof(GameModelDescriptor.Cost)}.TierCacheWrite", () => ComparableModel(cost: ComparableCost(tierCacheWrite: 18))), + (nameof(GameModelDescriptor.Metadata), () => ComparableModel(metadata: new Dictionary + { + ["family"] = "changed", + })), + (nameof(GameModelDescriptor.SamplingParametersJson), () => ComparableModel( + samplingParametersJson: "{\"temperature\":0.5}")), + (nameof(GameModelDescriptor.Headers), () => ComparableModel(headers: new Dictionary + { + ["X-Model-Mode"] = "changed", + })), + (nameof(GameModelDescriptor.CompatibilityJson), () => ComparableModel( + compatibilityJson: "{\"supportsTemperature\":false}")), + }; + + foreach (var change in changes) + { + var current = ComparableModel(); + var catalog = Catalog(Registration( + "provider", + new ScriptedProvider(), + Array.Empty(), + refresh: (_, _) => new ValueTask>(new[] { current }))); + + var first = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + var unchanged = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + current = change.Create(); + var changed = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameModelRefreshStatus.Updated, first.Status); + Assert.Equal(GameModelRefreshStatus.Unchanged, unchanged.Status); + Assert.True( + changed.Status == GameModelRefreshStatus.Updated, + $"Changing '{change.Field}' must produce an Updated refresh result, but produced '{changed.Status}'."); + } + } + [Fact] public async Task ReplacingProviderSupersedesAnInFlightRefreshEvenWhenItsSourceIgnoresCancellation() { @@ -101,6 +241,190 @@ public async Task ReplacingProviderSupersedesAnInFlightRefreshEvenWhenItsSourceI Assert.Equal("new", Assert.Single(catalog.GetModels("provider")).ModelId); } + [Fact] + public async Task RefreshStopsWaitingWhenAProviderIgnoresCallerCancellation() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var catalog = Catalog(Registration( + "provider", + new ScriptedProvider(), + new[] { Model("provider", "baseline") }, + async (_, _) => + { + entered.TrySetResult(true); + await release.Task.ConfigureAwait(false); + return new[] { Model("provider", "late") }; + })); + using var cancellation = new CancellationTokenSource(); + var refresh = catalog.RefreshAsync("provider", cancellationToken: cancellation.Token).AsTask(); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + var result = await refresh; + + Assert.Equal(GameModelRefreshStatus.Canceled, result.Status); + Assert.Equal("baseline", Assert.Single(catalog.GetModels("provider")).ModelId); + release.TrySetResult(true); + } + + [Fact] + public async Task SelectedRefreshIgnoresUnknownProviderIds() + { + var refreshes = 0; + var catalog = Catalog(Registration( + "known", + new ScriptedProvider(), + Array.Empty(), + (_, _) => + { + Interlocked.Increment(ref refreshes); + return new ValueTask>(new[] { Model("known", "model") }); + })); + + var results = await catalog.RefreshAsync( + new[] { "unknown", "known", "unknown" }, + cancellationToken: TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.Equal("known", result.ProviderId); + Assert.Equal(GameModelRefreshStatus.Updated, result.Status); + Assert.Equal(1, refreshes); + } + + [Fact] + public async Task AvailabilityStopsWaitingWhenAuthenticationIgnoresCancellation() + { + var authentication = new BlockingAuthentication(); + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("provider"), + new ScriptedProvider(), + authentication, + new[] { Model("provider", "model") })); + using var cancellation = new CancellationTokenSource(); + var available = catalog.GetAvailableModelsAsync( + "provider", + cancellation.Token).AsTask(); + await authentication.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => available); + authentication.Release.TrySetResult(true); + } + + [Fact] + public async Task CatalogAuthenticationFacadeIsCancellableAndWrapsProviderFailures() + { + var blocking = new BlockingAuthentication(); + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("blocking"), + new ScriptedProvider(), + blocking, + new[] { Model("blocking", "model") })); + using var cancellation = new CancellationTokenSource(); + var check = catalog.CheckAuthenticationAsync("blocking", cancellation.Token).AsTask(); + await blocking.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => check); + blocking.Release.TrySetResult(true); + + catalog.Register(new GameModelProviderRegistration( + new GameProviderDescriptor("throwing"), + new ScriptedProvider(), + new ThrowingAuthentication(), + new[] { Model("throwing", "model") })); + var error = await Assert.ThrowsAsync(() => + catalog.CheckAuthenticationAsync("throwing", TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("throwing", error.Message, StringComparison.Ordinal); + Assert.IsType(error.InnerException); + + await Assert.ThrowsAsync(() => + catalog.CheckAuthenticationAsync("missing", TestContext.Current.CancellationToken).AsTask()); + await Assert.ThrowsAsync(() => + catalog.ResolveAuthenticationAsync("missing", TestContext.Current.CancellationToken).AsTask()); + await Assert.ThrowsAsync(() => + catalog.LoginAsync( + "missing", + "api-key", + new GameAuthInteraction(), + TestContext.Current.CancellationToken).AsTask()); + await Assert.ThrowsAsync(() => + catalog.LogoutAsync("missing", TestContext.Current.CancellationToken).AsTask()); + } + + [Fact] + public async Task CatalogAuthenticationFacadeRunsLoginResolveAndLogoutThroughOneRegistration() + { + var authentication = new RecordingAuthentication(); + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("provider"), + new ScriptedProvider(), + authentication, + new[] { Model("provider", "model") })); + + var status = await catalog.CheckAuthenticationAsync("provider", TestContext.Current.CancellationToken); + var resolution = await catalog.ResolveAuthenticationAsync("provider", TestContext.Current.CancellationToken); + var credential = await catalog.LoginAsync( + "provider", + "api-key", + new GameAuthInteraction(), + TestContext.Current.CancellationToken); + await catalog.LogoutAsync("provider", TestContext.Current.CancellationToken); + + Assert.True(status.Configured); + Assert.Equal("resolved", resolution!.Credential!.Secret); + Assert.Equal("logged-in", credential.Secret); + Assert.Equal(1, authentication.CheckCount); + Assert.Equal(1, authentication.ResolveCount); + Assert.Equal(1, authentication.LoginCount); + Assert.Equal(1, authentication.LogoutCount); + } + + [Fact] + public async Task CatalogDeferredFacadeAuthenticatesValidatesIdentityAndForwardsCustomProvider() + { + var authentication = new RecordingAuthentication(); + var provider = new DeferredProvider(); + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("provider"), + provider, + authentication, + new[] { ComparableModel(api: "deferred-api") })); + var handle = new DeferredModelHandle("provider", "model", "deferred-api", "job-1"); + + var events = new List(); + await foreach (var streamEvent in catalog.FetchDeferredAsync( + handle, + TimeSpan.FromSeconds(2), + TestContext.Current.CancellationToken)) + { + events.Add(streamEvent); + } + await catalog.CancelDeferredAsync(handle, TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.Same(handle, provider.FetchedHandle); + Assert.Same(handle, provider.CanceledHandle); + Assert.Equal(TimeSpan.FromSeconds(2), provider.Wait); + Assert.Equal(2, authentication.CheckCount); + Assert.Equal(2, authentication.ResolveCount); + + var unsupported = Catalog(Registration( + "plain", + new ScriptedProvider(), + ComparableModel(api: "deferred-api", providerId: "plain"))); + var unsupportedError = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in unsupported.FetchDeferredAsync( + new DeferredModelHandle("plain", "model", "deferred-api", "job-2"), + TimeSpan.Zero, + TestContext.Current.CancellationToken)) + { + } + }); + Assert.False(unsupportedError.IsTransient); + } + [Fact] public async Task ThrowingRefreshCancellationCallbacksCannotBlockProviderReplacement() { @@ -285,7 +609,7 @@ await store.SetAsync( authentication.ResolveAsync(TestContext.Current.CancellationToken).AsTask()); Assert.Equal(1, refreshes); - Assert.All(resolved, value => Assert.Equal("refreshed", value!.Credential.Secret)); + Assert.All(resolved, value => Assert.Equal("refreshed", value!.Credential!.Secret)); var login = await authentication.LoginAsync( "oauth", new GameAuthInteraction(), @@ -296,6 +620,158 @@ await store.SetAsync( Assert.Throws(() => new GameCredential(GameCredentialKind.ApiKey, "unsafe\r\nvalue")); } + [Fact] + public async Task StoredAuthenticationRefreshesCredentialsInsideTheDefaultFiveMinuteWindow() + { + var store = new InMemoryGameCredentialStore(); + var now = DateTimeOffset.UnixEpoch.AddHours(3); + var key = new GameCredentialKey("provider"); + await store.SetAsync( + key, + new GameCredential(GameCredentialKind.OAuth, "expiring", now.AddMinutes(4)), + TestContext.Current.CancellationToken); + var refreshes = 0; + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + refresh: (_, _) => + { + Interlocked.Increment(ref refreshes); + return new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "fresh", now.AddHours(1))); + }, + clock: () => now); + + var resolved = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, refreshes); + Assert.Equal("fresh", resolved!.Credential!.Secret); + Assert.Equal("fresh", (await store.GetAsync(key, TestContext.Current.CancellationToken))!.Secret); + } + + [Fact] + public async Task StoredAuthenticationTimesOutANonCooperativeRefreshWithoutLateCommit() + { + var store = new InMemoryGameCredentialStore(); + var now = DateTimeOffset.UnixEpoch.AddHours(3); + var key = new GameCredentialKey("provider"); + var original = new GameCredential(GameCredentialKind.OAuth, "expired", now.AddMinutes(-1)); + await store.SetAsync(key, original, TestContext.Current.CancellationToken); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + refresh: (_, _) => new ValueTask(release.Task), + clock: () => now, + refreshSkew: TimeSpan.Zero, + refreshTimeoutMilliseconds: 100); + + await Assert.ThrowsAsync(async () => + await authentication.ResolveAsync(TestContext.Current.CancellationToken)); + Assert.Same(original, await store.GetAsync(key, TestContext.Current.CancellationToken)); + + release.TrySetResult(new GameCredential(GameCredentialKind.OAuth, "late", now.AddHours(1))); + await Task.Yield(); + Assert.Same(original, await store.GetAsync(key, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CredentialMutationsSerializePerProviderWithoutBlockingOtherProviders() + { + var store = new InMemoryGameCredentialStore(); + var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = store.ModifyAsync( + new GameCredentialKey("first"), + async (_, _) => + { + firstEntered.TrySetResult(true); + await releaseFirst.Task.ConfigureAwait(false); + return new GameCredential(GameCredentialKind.ApiKey, "first-secret"); + }, + TestContext.Current.CancellationToken).AsTask(); + await firstEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var second = await store.ModifyAsync( + new GameCredentialKey("second"), + (_, _) => new ValueTask( + new GameCredential(GameCredentialKind.ApiKey, "second-secret")), + TestContext.Current.CancellationToken); + + Assert.Equal("second-secret", second!.Secret); + Assert.False(first.IsCompleted); + releaseFirst.TrySetResult(true); + Assert.Equal("first-secret", (await first)!.Secret); + } + + [Fact] + public async Task CanceledQueuedCredentialMutationNeverRunsLater() + { + var store = new InMemoryGameCredentialStore(); + var key = new GameCredentialKey("provider"); + var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = store.ModifyAsync( + key, + async (_, _) => + { + firstEntered.TrySetResult(true); + await releaseFirst.Task.ConfigureAwait(false); + return new GameCredential(GameCredentialKind.ApiKey, "first"); + }, + TestContext.Current.CancellationToken).AsTask(); + await firstEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + + using var cancellation = new CancellationTokenSource(); + var secondRan = false; + var second = store.ModifyAsync( + key, + (_, _) => + { + secondRan = true; + return new ValueTask(new GameCredential(GameCredentialKind.ApiKey, "second")); + }, + cancellation.Token).AsTask(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => second); + releaseFirst.TrySetResult(true); + await first; + await Task.Yield(); + Assert.False(secondRan); + Assert.Equal("first", (await store.GetAsync(key, TestContext.Current.CancellationToken))!.Secret); + } + + [Fact] + public async Task ActiveCredentialMutationStopsWaitingOnCancellationAndCannotCommitLate() + { + var store = new InMemoryGameCredentialStore(); + var key = new GameCredentialKey("provider"); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var mutation = store.ModifyAsync( + key, + async (_, _) => + { + entered.TrySetResult(true); + await release.Task.ConfigureAwait(false); + return new GameCredential(GameCredentialKind.ApiKey, "late"); + }, + cancellation.Token).AsTask(); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => mutation); + release.TrySetResult(true); + + await store.SetAsync( + key, + new GameCredential(GameCredentialKind.ApiKey, "current"), + TestContext.Current.CancellationToken); + Assert.Equal("current", (await store.GetAsync(key, TestContext.Current.CancellationToken))!.Secret); + } + [Fact] public void CredentialExpiryHandlesBoundaryClocksWithoutOverflow() { @@ -311,6 +787,48 @@ public void CredentialExpiryHandlesBoundaryClocksWithoutOverflow() TimeSpan.FromSeconds(-1))); } + [Fact] + public void AuthenticationResolutionSnapshotsRequestAuthWithoutLeakingMutableState() + { + var headers = new Dictionary + { + ["Authorization"] = "Bearer token", + ["X-Suppressed"] = null, + }; + var configuration = new Dictionary { ["ACCOUNT_ID"] = "account" }; + var resolution = new GameProviderAuthResolution( + credential: null, + source: "ambient", + baseUrl: new Uri("https://provider.example/v1"), + headers, + configuration); + + headers["Authorization"] = "changed"; + configuration["ACCOUNT_ID"] = "changed"; + + Assert.Null(resolution.Credential); + Assert.Equal("https://provider.example/v1", resolution.BaseUrl!.OriginalString); + Assert.Equal("Bearer token", resolution.Headers["authorization"]); + Assert.Null(resolution.Headers["x-suppressed"]); + Assert.Equal("account", resolution.Configuration["account_id"]); + Assert.Throws(() => new GameProviderAuthResolution( + null, + "ambient", + headers: new Dictionary + { + ["Authorization"] = "one", + ["authorization"] = "two", + })); + Assert.Throws(() => new GameProviderAuthResolution( + null, + "ambient", + headers: new Dictionary { ["X-Unsafe"] = "value\r\ninjected" })); + Assert.Throws(() => new GameProviderAuthResolution( + null, + "ambient", + baseUrl: new Uri("https://user:secret@provider.example/v1"))); + } + [Fact] public async Task StoredAuthenticationNeverCommitsExpiredLoginOrRefreshResults() { @@ -339,6 +857,43 @@ await Assert.ThrowsAsync(async () => Assert.Same(original, await store.GetAsync(key, TestContext.Current.CancellationToken)); } + [Fact] + public async Task CancellingLoginWhileCredentialCommitIsQueuedNeverStoresTheCredential() + { + var store = new InMemoryGameCredentialStore(); + var key = new GameCredentialKey("provider"); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocker = store.ModifyAsync( + key, + async (current, cancellationToken) => + { + entered.TrySetResult(true); + await release.Task.WaitAsync(cancellationToken); + return current; + }, + TestContext.Current.CancellationToken).AsTask(); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + schemes: new[] { "oauth" }, + login: (_, _, _) => new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "must-not-be-stored"))); + using var cancellation = new CancellationTokenSource(); + var login = authentication.LoginAsync( + "oauth", + new GameAuthInteraction(), + cancellation.Token).AsTask(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => login); + release.TrySetResult(true); + await blocker; + Assert.Null(await store.GetAsync(key, TestContext.Current.CancellationToken)); + } + [Fact] public async Task EnvironmentAuthenticationResolvesPerRequestWithoutExposingSecretsInStatus() { @@ -354,8 +909,8 @@ public async Task EnvironmentAuthenticationResolvesPerRequestWithoutExposingSecr Assert.True(status.Configured); Assert.DoesNotContain("first", status.Source, StringComparison.Ordinal); - Assert.Equal("first", first!.Credential.Secret); - Assert.Equal("second", second!.Credential.Secret); + Assert.Equal("first", first!.Credential!.Secret); + Assert.Equal("second", second!.Credential!.Secret); Assert.DoesNotContain("second", second.Credential.ToString(), StringComparison.Ordinal); } @@ -505,7 +1060,7 @@ private static async IAsyncEnumerable Capture( ConcurrentQueue secrets, [EnumeratorCancellation] CancellationToken cancellationToken) { - secrets.Enqueue(authentication?.Credential.Secret); + secrets.Enqueue(authentication?.Credential?.Secret); await foreach (var streamEvent in provider.StreamAsync(request, cancellationToken).WithCancellation(cancellationToken)) { yield return streamEvent; @@ -519,7 +1074,7 @@ private static async IAsyncEnumerable CaptureSecret( Action capture, [EnumeratorCancellation] CancellationToken cancellationToken) { - capture(authentication?.Credential.Secret); + capture(authentication?.Credential?.Secret); await foreach (var streamEvent in provider.StreamAsync(request, cancellationToken).WithCancellation(cancellationToken)) { yield return streamEvent; @@ -576,6 +1131,71 @@ private static GameModelDescriptor Model( cost: cost, reasoningLevelValues: reasoningLevelValues); + private static GameModelDescriptor ComparableModel( + string modelId = "model", + string displayName = "Model", + string api = "openai-completions", + string baseUrl = "https://example.invalid/v1", + int contextWindowTokens = 100_000, + int maximumOutputTokens = 8_000, + GameModelInputCapabilities inputCapabilities = GameModelInputCapabilities.Text | GameModelInputCapabilities.StructuredData, + GameModelOutputCapabilities outputCapabilities = GameModelOutputCapabilities.Text + | GameModelOutputCapabilities.ToolCalls + | GameModelOutputCapabilities.Reasoning, + IReadOnlyCollection? reasoningLevels = null, + IReadOnlyDictionary? reasoningLevelValues = null, + GameModelCost? cost = null, + IReadOnlyDictionary? metadata = null, + string samplingParametersJson = "{\"temperature\":0.2}", + IReadOnlyDictionary? headers = null, + string compatibilityJson = "{\"supportsTemperature\":true}", + string providerId = "provider") => + new( + providerId, + modelId, + displayName, + contextWindowTokens, + maximumOutputTokens, + inputCapabilities, + outputCapabilities, + reasoningLevels ?? new[] { GameReasoningLevel.Low, GameReasoningLevel.High }, + cost ?? ComparableCost(), + metadata ?? new Dictionary { ["family"] = "baseline" }, + reasoningLevelValues ?? new Dictionary + { + [GameReasoningLevel.Low] = "baseline-low", + }, + api, + new Uri(baseUrl), + samplingParametersJson, + headers ?? new Dictionary { ["X-Model-Mode"] = "baseline" }, + compatibilityJson); + + private static GameModelCost ComparableCost( + decimal input = 1, + decimal output = 2, + decimal cacheRead = 3, + decimal cacheWrite = 4, + long tierAbove = 50_000, + decimal tierInput = 5, + decimal tierOutput = 6, + decimal tierCacheRead = 7, + decimal tierCacheWrite = 8) => + new( + input, + output, + cacheRead, + cacheWrite, + new[] + { + new GameModelCostTier( + tierAbove, + tierInput, + tierOutput, + tierCacheRead, + tierCacheWrite), + }); + private sealed class ScriptedProvider : IModelProvider { public ConcurrentQueue Requests { get; } = new(); @@ -640,4 +1260,140 @@ public ValueTask SaveAsync( } } } + + private sealed class ThrowingAuthentication : IGameProviderAuthentication + { + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) => + ValueTask.FromException(new FormatException("broken auth")); + + public ValueTask ResolveAsync(CancellationToken cancellationToken) => + new((GameProviderAuthResolution?)null); + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + ValueTask.FromException(new InvalidOperationException()); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + private sealed class RecordingAuthentication : IGameProviderAuthentication + { + public IReadOnlyCollection Schemes { get; } = new[] { "api-key" }; + + public int CheckCount { get; private set; } + + public int ResolveCount { get; private set; } + + public int LoginCount { get; private set; } + + public int LogoutCount { get; private set; } + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CheckCount++; + return new ValueTask(new GameProviderAuthStatus(true, "recording")); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ResolveCount++; + return new ValueTask(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.ApiKey, "resolved"), + "recording")); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + LoginCount++; + return new ValueTask(new GameCredential(GameCredentialKind.ApiKey, "logged-in")); + } + + public ValueTask LogoutAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + LogoutCount++; + return ValueTask.CompletedTask; + } + } + + private sealed class DeferredProvider : IDeferredModelProvider + { + public DeferredModelHandle? FetchedHandle { get; private set; } + + public DeferredModelHandle? CanceledHandle { get; private set; } + + public TimeSpan Wait { get; private set; } + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public async IAsyncEnumerable FetchDeferredAsync( + DeferredModelHandle handle, + TimeSpan wait, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + FetchedHandle = handle; + Wait = wait; + await Task.Yield(); + yield return ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("ready") }, + ModelStopReason.Stop)); + } + + public ValueTask CancelDeferredAsync( + DeferredModelHandle handle, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CanceledHandle = handle; + return ValueTask.CompletedTask; + } + } + + private sealed class BlockingAuthentication : IGameProviderAuthentication + { + public TaskCompletionSource Entered { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public async ValueTask CheckAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + Entered.TrySetResult(true); + await Release.Task.ConfigureAwait(false); + return new GameProviderAuthStatus(true, "blocking"); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask((GameProviderAuthResolution?)null); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException(); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException(); + } } diff --git a/tests/OpenGameAgent.Models.Tests/OAuthFlowTests.cs b/tests/OpenGameAgent.Models.Tests/OAuthFlowTests.cs new file mode 100644 index 0000000..f4c2fa6 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/OAuthFlowTests.cs @@ -0,0 +1,350 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace OpenGameAgent.Models.Tests; + +public sealed class OAuthFlowTests +{ + [Fact] + public async Task AuthorizationCodeUsesPkceStateAndProducesRefreshableCredential() + { + var handler = new QueueHandler(_ => Json(HttpStatusCode.OK, """ + {"access_token":"access","refresh_token":"refresh","token_type":"Bearer","expires_in":3600,"scope":"models.read"} + """)); + var options = new GameOAuthAuthorizationCodeOptions( + new HttpClient(handler), + new Uri("https://auth.example.test/authorize"), + new Uri("https://auth.example.test/token"), + "client", + new Uri("http://127.0.0.1:1455/callback")); + options.Scopes.Add("models.read"); + Uri? opened = null; + var interaction = new GameAuthInteraction + { + OpenBrowserAsync = (uri, _) => + { + opened = uri; + return ValueTask.CompletedTask; + }, + PromptAsync = (_, _, _) => + { + var state = ParseQuery(opened!.Query)["state"]; + return new ValueTask("http://127.0.0.1:1455/callback?code=authorization-code&state=" + Uri.EscapeDataString(state)); + }, + }; + + var credential = await GameOAuth.LoginAuthorizationCodeAsync( + options, + interaction, + TestContext.Current.CancellationToken); + + Assert.Equal(GameCredentialKind.OAuth, credential.Kind); + Assert.Equal("access", credential.Secret); + Assert.Equal("refresh", credential.Metadata["refresh_token"]); + Assert.NotNull(credential.ExpiresAt); + var authorizationQuery = ParseQuery(opened!.Query); + var form = ParseForm(handler.Bodies.Single()); + Assert.Equal("S256", authorizationQuery["code_challenge_method"]); + Assert.Equal("authorization_code", form["grant_type"]); + Assert.Equal("authorization-code", form["code"]); + Assert.Equal( + authorizationQuery["code_challenge"], + Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(form["code_verifier"])))); + } + + [Fact] + public async Task DeviceCodeHonorsPendingAndSlowDownBeforeSuccess() + { + var handler = new QueueHandler( + _ => Json(HttpStatusCode.OK, """ + {"device_code":"device","user_code":"ABCD","verification_uri":"https://auth.example.test/device","expires_in":600,"interval":0} + """), + _ => Json(HttpStatusCode.BadRequest, "{\"error\":\"authorization_pending\"}"), + _ => Json(HttpStatusCode.BadRequest, "{\"error\":\"slow_down\"}"), + _ => Json(HttpStatusCode.OK, "{\"access_token\":\"access\",\"refresh_token\":\"refresh\",\"expires_in\":3600}")); + var options = new GameOAuthDeviceCodeOptions( + new HttpClient(handler), + new Uri("https://auth.example.test/device/code"), + new Uri("https://auth.example.test/token"), + "client"); + var delays = new List(); + options.DelayAsync = (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }; + string? notification = null; + Uri? opened = null; + var interaction = new GameAuthInteraction + { + NotifyAsync = (message, _) => + { + notification = message; + return ValueTask.CompletedTask; + }, + OpenBrowserAsync = (uri, _) => + { + opened = uri; + return ValueTask.CompletedTask; + }, + }; + + var credential = await GameOAuth.LoginDeviceCodeAsync( + options, + interaction, + TestContext.Current.CancellationToken); + + Assert.Equal("access", credential.Secret); + Assert.Contains("ABCD", notification, StringComparison.Ordinal); + Assert.Equal("https://auth.example.test/device", opened!.AbsoluteUri); + Assert.Equal(new[] { TimeSpan.Zero, TimeSpan.Zero, TimeSpan.FromSeconds(5) }, delays); + Assert.Equal(4, handler.Bodies.Count); + Assert.Equal("device", ParseForm(handler.Bodies.Last())["device_code"]); + } + + [Fact] + public async Task RefreshPreservesRotatingRefreshTokenWhenResponseOmitsIt() + { + var handler = new QueueHandler(_ => Json(HttpStatusCode.OK, "{\"access_token\":\"next\",\"expires_in\":120}")); + var current = new GameCredential( + GameCredentialKind.OAuth, + "current", + DateTimeOffset.UtcNow.AddMinutes(1), + new Dictionary { ["refresh_token"] = "refresh" }); + + var next = await GameOAuth.RefreshAsync( + new HttpClient(handler), + new Uri("https://auth.example.test/token"), + "client", + current, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("next", next.Secret); + Assert.Equal("refresh", next.Metadata["refresh_token"]); + Assert.Equal("refresh", ParseForm(handler.Bodies.Single())["refresh_token"]); + } + + [Fact] + public async Task AuthorizationCodeRejectsMismatchedCallbackStateBeforeTokenExchange() + { + var handler = new QueueHandler(_ => throw new InvalidOperationException("must not send")); + var options = new GameOAuthAuthorizationCodeOptions( + new HttpClient(handler), + new Uri("https://auth.example.test/authorize"), + new Uri("https://auth.example.test/token"), + "client", + new Uri("http://127.0.0.1:1455/callback")); + var interaction = new GameAuthInteraction + { + OpenBrowserAsync = (_, _) => ValueTask.CompletedTask, + PromptAsync = (_, _, _) => new ValueTask( + "http://127.0.0.1:1455/callback?code=code&state=wrong"), + }; + + await Assert.ThrowsAsync(async () => + await GameOAuth.LoginAuthorizationCodeAsync( + options, + interaction, + TestContext.Current.CancellationToken)); + Assert.Empty(handler.Bodies); + } + + [Fact] + public async Task TokenExchangeRejectsOversizedResponsesBeforeReadingTheWholeBody() + { + var content = new CountingContent(4_000_000); + var handler = new QueueHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var credential = new GameCredential( + GameCredentialKind.OAuth, + "current", + metadata: new Dictionary { ["refresh_token"] = "refresh" }); + + await Assert.ThrowsAsync(async () => + await GameOAuth.RefreshAsync( + new HttpClient(handler), + new Uri("https://auth.example.test/token"), + "client", + credential, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.InRange(content.BytesRead, 1_000_001, 1_020_000); + } + + [Fact] + public async Task TokenExchangeCancellationDoesNotWaitForANonCooperativeResponseStream() + { + var content = new PendingStreamContent(); + var handler = new QueueHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var credential = new GameCredential( + GameCredentialKind.OAuth, + "current", + metadata: new Dictionary { ["refresh_token"] = "refresh" }); + using var cancellation = new CancellationTokenSource(); + var refresh = GameOAuth.RefreshAsync( + new HttpClient(handler), + new Uri("https://auth.example.test/token"), + "client", + credential, + cancellationToken: cancellation.Token).AsTask(); + await content.ReadRequested.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => refresh); + content.Release(new MemoryStream(Encoding.UTF8.GetBytes("{}"))); + } + + [Fact] + public async Task TokenExchangeRejectsMalformedJsonWithoutExposingTheResponse() + { + const string malformed = "{\"access_token\":\"sensitive\","; + var handler = new QueueHandler(_ => Json(HttpStatusCode.OK, malformed)); + var credential = new GameCredential( + GameCredentialKind.OAuth, + "current", + metadata: new Dictionary { ["refresh_token"] = "refresh" }); + + var error = await Assert.ThrowsAsync(async () => + await GameOAuth.RefreshAsync( + new HttpClient(handler), + new Uri("https://auth.example.test/token"), + "client", + credential, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.DoesNotContain("sensitive", error.Message, StringComparison.Ordinal); + } + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + private static Dictionary ParseQuery(string query) => + ParseEncoded(query.TrimStart('?')); + + private static Dictionary ParseForm(string form) => ParseEncoded(form); + + private static Dictionary ParseEncoded(string value) => + value.Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split(new[] { '=' }, 2)) + .ToDictionary( + part => Uri.UnescapeDataString(part[0].Replace('+', ' ')), + part => Uri.UnescapeDataString((part.Length == 2 ? part[1] : string.Empty).Replace('+', ' ')), + StringComparer.Ordinal); + + private static string Base64Url(byte[] value) => + Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed class QueueHandler : HttpMessageHandler + { + private readonly Queue> _responses; + + public QueueHandler(params Func[] responses) + { + _responses = new Queue>(responses); + } + + public List Bodies { get; } = new(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + return _responses.Dequeue()(request); + } + } + + private sealed class CountingContent : HttpContent + { + private readonly int _length; + + public CountingContent(int length) + { + _length = length; + } + + public int BytesRead { get; private set; } + + protected override Task CreateContentReadStreamAsync() => + Task.FromResult(new CountingStream(_length, count => BytesRead += count)); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + throw new NotSupportedException(); + + protected override bool TryComputeLength(out long length) + { + length = _length; + return true; + } + } + + private sealed class CountingStream : Stream + { + private readonly int _length; + private readonly Action _count; + private int _position; + + public CountingStream(int length, Action count) + { + _length = length; + _count = count; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + var read = Math.Min(count, _length - _position); + Array.Fill(buffer, (byte)'x', offset, read); + _position += read; + _count(read); + return read; + } + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) => Task.FromResult(Read(buffer, offset, count)); + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private sealed class PendingStreamContent : HttpContent + { + private readonly TaskCompletionSource _stream = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReadRequested { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Release(Stream stream) => _stream.TrySetResult(stream); + + protected override Task CreateContentReadStreamAsync() + { + ReadRequested.TrySetResult(true); + return _stream.Task; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } +} diff --git a/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj b/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj index b190f33..63dfb2e 100644 --- a/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj +++ b/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj @@ -16,5 +16,15 @@ + + + + + + + + + + diff --git a/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs new file mode 100644 index 0000000..15820e8 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs @@ -0,0 +1,22 @@ +using OpenGameAgent.Models; +using OpenGameAgent.Testing; +using Xunit; + +namespace OpenGameAgent.Models.Tests; + +public sealed class PublicApiCompatibilityTests +{ + private const string ApprovedApiHash = "2E7B2A1A0F19FF66AE6672B5B1F82E55BCC9F25149DCC90BF7A6814009D8B073"; + + [Fact] + public void ModelsPublicApiMatchesTheApprovedStableSurface() + { + var assembly = typeof(GameModelCatalog).Assembly; + var surface = PublicApiSurface.Describe(assembly); + var hash = PublicApiSurface.Hash(assembly); + + Assert.True( + string.Equals(ApprovedApiHash, hash, StringComparison.Ordinal), + $"The Models public API changed. Review the complete surface below, then update the approved hash intentionally.\nHash: {hash}\n\n{surface}"); + } +} diff --git a/tests/OpenGameAgent.Models.Tests/packages.lock.json b/tests/OpenGameAgent.Models.Tests/packages.lock.json index 2b6fc63..df218c8 100644 --- a/tests/OpenGameAgent.Models.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Models.Tests/packages.lock.json @@ -39,6 +39,45 @@ "xunit.v3.mtp-v1": "[3.2.2]" } }, + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==" + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.23.0", @@ -112,11 +151,29 @@ "System.Security.Principal.Windows": "5.0.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "8.0.0", "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, "System.Reflection.Metadata": { "type": "Transitive", "resolved": "8.0.0", @@ -209,6 +266,13 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" + } + }, "opengameagent.kernel": { "type": "Project", "dependencies": { @@ -218,8 +282,61 @@ "opengameagent.models": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openaicompatible": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" } } } diff --git a/tests/OpenGameAgent.Persistence.Tests/FileGameSessionHistoryTests.cs b/tests/OpenGameAgent.Persistence.Tests/FileGameSessionHistoryTests.cs new file mode 100644 index 0000000..65ff8b2 --- /dev/null +++ b/tests/OpenGameAgent.Persistence.Tests/FileGameSessionHistoryTests.cs @@ -0,0 +1,291 @@ +using System.Text; +using Xunit; + +#pragma warning disable xUnit1051 // File operations are bounded; lock cancellation has a dedicated test. + +namespace OpenGameAgent.Persistence.Tests; + +public sealed class FileGameSessionHistoryTests +{ + [Fact] + public async Task RestartRoundTripsTreeRecordsFactsAndSharedSequence() + { + using var directory = new TemporaryDirectory(); + var repository = CreateRepository(directory.Path); + var history = await repository.CreateAsync(new GameHistoryCreateOptions + { + Id = "session", + MetadataJson = "{\"world\":\"one\"}", + }); + var root = await history.AppendEntryAsync("root", "turn", "{\"x\":1.5}", mutationId: "m1"); + await history.CreateLaneAsync("npc", root.Entry.Id, mutationId: "m2"); + await history.AppendEntryAsync("npc", "turn", "{\"x\":2}", lane: "npc", mutationId: "m3"); + await history.AppendRecordAsync("record", "decision", "{\"ok\":true}", lane: "npc", mutationId: "m4"); + await history.SetNameAsync("World", mutationId: "m5"); + await history.SetLabelAsync("npc", "tip", mutationId: "m6"); + + var reopened = await CreateRepository(directory.Path).OpenAsync("session"); + + Assert.Equal(new[] { "root", "npc" }, (await reopened.FindEntriesAsync(new GameHistoryEntryQuery + { + Order = GameHistoryOrder.OldestFirst, + })).Items.Select(entry => entry.Id)); + Assert.Equal("decision", Assert.Single((await reopened.FindRecordsAsync()).Items).Type); + Assert.Equal("World", await reopened.GetNameAsync()); + Assert.Equal("tip", await reopened.GetLabelAsync("npc")); + Assert.Equal(new[] { 1L, 2L, 3L, 4L, 5L, 6L }, (await reopened.GetLogAsync(new GameHistoryLogQuery + { + Limit = 20, + })).Items.Select(item => item.Sequence)); + Assert.Contains("1.5", (await reopened.GetEntryAsync("root"))!.PayloadJson, StringComparison.Ordinal); + } + + [Fact] + public async Task IndependentRepositoriesLinearizeWritesAndRetryStableMutations() + { + using var directory = new TemporaryDirectory(); + var firstRepository = CreateRepository(directory.Path); + var secondRepository = CreateRepository(directory.Path); + var first = await firstRepository.CreateAsync(new GameHistoryCreateOptions { Id = "session" }); + var second = await secondRepository.OpenAsync("session"); + var writes = Enumerable.Range(0, 24).Select(index => + (index % 2 == 0 ? first : second).AppendEntryAsync( + $"entry-{index}", + "event", + $"{{\"index\":{index}}}", + mutationId: $"mutation-{index}")); + + var results = await Task.WhenAll(writes); + var reopened = await CreateRepository(directory.Path).OpenAsync("session"); + var entries = (await reopened.FindEntriesAsync(new GameHistoryEntryQuery + { + Order = GameHistoryOrder.OldestFirst, + Limit = 100, + })).Items; + + Assert.Equal(24, entries.Count); + Assert.Equal(24, results.Select(result => result.Entry.Sequence).Distinct().Count()); + Assert.Equal(Enumerable.Range(1, 24).Select(value => (long)value), entries.Select(entry => entry.Sequence)); + var retry = await second.AppendEntryAsync("entry-0", "event", "{\"index\":0}", mutationId: "mutation-0", expectedSequence: 0); + Assert.True(retry.Commit.Replayed); + var conflict = await Assert.ThrowsAsync(() => + first.AppendEntryAsync("late", "event", "{}", mutationId: "late", expectedSequence: 1)); + Assert.Equal(24, conflict.ActualSequence); + } + + [Fact] + public async Task CrossRepositoryCreateAndMutationRacesPublishExactlyOnce() + { + using var directory = new TemporaryDirectory(); + var firstRepository = CreateRepository(directory.Path); + var secondRepository = CreateRepository(directory.Path); + var creates = await Task.WhenAll( + CaptureAsync(() => firstRepository.CreateAsync(new GameHistoryCreateOptions { Id = "session" })), + CaptureAsync(() => secondRepository.CreateAsync(new GameHistoryCreateOptions { Id = "session" }))); + Assert.Single(creates, result => result.History is not null); + Assert.Single(creates, result => result.Error?.Code == GameHistoryErrorCode.AlreadyExists); + + var first = await firstRepository.OpenAsync("session"); + var second = await secondRepository.OpenAsync("session"); + var commits = await Task.WhenAll( + first.AppendEntryAsync("entry", "event", "{\"value\":1}", mutationId: "shared-mutation"), + second.AppendEntryAsync("entry", "event", "{\"value\":1}", mutationId: "shared-mutation")); + + Assert.Single(commits, commit => !commit.Commit.Replayed); + Assert.Single(commits, commit => commit.Commit.Replayed); + Assert.Single((await (await CreateRepository(directory.Path).OpenAsync("session")).FindEntriesAsync()).Items); + } + + [Fact] + public async Task RepairsOnlyATornSyntaxTailAndRejectsCompleteOrMiddleCorruption() + { + using var directory = new TemporaryDirectory(); + var repository = CreateRepository(directory.Path); + var history = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "torn" }); + await history.AppendEntryAsync("kept", "event", "{}"); + var path = SessionPath(directory.Path, "torn"); + await File.AppendAllTextAsync(path, "{\"Kind\":\"mutation\"", Encoding.UTF8); + + var repaired = await CreateRepository(directory.Path).OpenAsync("torn"); + Assert.Equal("kept", Assert.Single((await repaired.FindEntriesAsync()).Items).Id); + Assert.EndsWith("\n", await File.ReadAllTextAsync(path), StringComparison.Ordinal); + await repaired.AppendEntryAsync("after", "event", "{}"); + var verified = await CreateRepository(directory.Path).OpenAsync("torn"); + Assert.Equal(2, (await verified.FindEntriesAsync()).Items.Count); + + var unterminated = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "unterminated" }); + await unterminated.AppendEntryAsync("valid", "event", "{}"); + var unterminatedPath = SessionPath(directory.Path, "unterminated"); + await File.WriteAllTextAsync(unterminatedPath, (await File.ReadAllTextAsync(unterminatedPath)).TrimEnd('\r', '\n')); + var reopenedUnterminated = await CreateRepository(directory.Path).OpenAsync("unterminated"); + Assert.Equal("valid", Assert.Single((await reopenedUnterminated.FindEntriesAsync()).Items).Id); + Assert.EndsWith("\n", await File.ReadAllTextAsync(unterminatedPath), StringComparison.Ordinal); + + var complete = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "complete" }); + await complete.AppendEntryAsync("kept", "event", "{}"); + await File.AppendAllTextAsync(SessionPath(directory.Path, "complete"), "{\"Kind\":\"unknown\"}\n", Encoding.UTF8); + var completeError = await Assert.ThrowsAsync(() => CreateRepository(directory.Path).OpenAsync("complete")); + Assert.Equal(GameHistoryErrorCode.CorruptStorage, completeError.Code); + + var semantic = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "semantic" }); + await semantic.AppendEntryAsync("kept", "event", "{}"); + var semanticPath = SessionPath(directory.Path, "semantic"); + const string invalidCompleteMutation = "{\"Kind\":\"mutation\",\"MutationId\":\"bad\",\"Sequence\":2,\"MutationKind\":\"Entry\"}"; + await File.AppendAllTextAsync(semanticPath, invalidCompleteMutation, Encoding.UTF8); + var beforeRejectedOpen = await File.ReadAllTextAsync(semanticPath); + var semanticError = await Assert.ThrowsAsync(() => CreateRepository(directory.Path).OpenAsync("semantic")); + Assert.Equal(GameHistoryErrorCode.CorruptStorage, semanticError.Code); + Assert.Equal(beforeRejectedOpen, await File.ReadAllTextAsync(semanticPath)); + + var middle = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "middle" }); + await middle.AppendEntryAsync("one", "event", "{}"); + await middle.AppendEntryAsync("two", "event", "{}"); + var middlePath = SessionPath(directory.Path, "middle"); + var lines = (await File.ReadAllLinesAsync(middlePath)).ToList(); + lines.Insert(2, "not-json"); + await File.WriteAllLinesAsync(middlePath, lines); + var middleError = await Assert.ThrowsAsync(() => CreateRepository(directory.Path).OpenAsync("middle")); + Assert.Equal(GameHistoryErrorCode.CorruptStorage, middleError.Code); + } + + [Fact] + public async Task ForkPersistsSelectedTreeAndLeavesRecordsBehind() + { + using var directory = new TemporaryDirectory(); + var repository = CreateRepository(directory.Path); + var source = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "source" }); + var root = await source.AppendEntryAsync("root", "turn", "{}"); + await source.CreateLaneAsync("npc", root.Entry.Id); + var main = await source.AppendEntryAsync("main", "turn", "{}"); + await source.AppendEntryAsync("npc", "turn", "{}", lane: "npc"); + await source.AppendRecordAsync("record", "operation", "{}"); + await source.SetLabelAsync("root", "root-label"); + var sourceSequence = (await source.GetStatsAsync()).LastSequence; + + var branch = await repository.ForkAsync("source", new GameHistoryForkOptions + { + Id = "branch", + EntryId = main.Entry.Id, + ExpectedSourceSequence = sourceSequence, + }); + var tree = await repository.ForkAsync("source", new GameHistoryForkOptions + { + Id = "tree", + Scope = GameHistoryForkScope.Tree, + }); + var reopenedTree = await CreateRepository(directory.Path).OpenAsync("tree"); + + Assert.Equal(new[] { "root", "main" }, (await branch.FindEntriesAsync(new GameHistoryEntryQuery + { + Order = GameHistoryOrder.OldestFirst, + })).Items.Select(entry => entry.Id)); + Assert.Empty((await branch.FindRecordsAsync()).Items); + Assert.Equal("root-label", await branch.GetLabelAsync("root")); + Assert.Equal(2, (await reopenedTree.GetLanesAsync()).Count); + Assert.Empty((await reopenedTree.FindRecordsAsync()).Items); + await Assert.ThrowsAsync(() => repository.ForkAsync("source", new GameHistoryForkOptions + { + Id = "conflict", + ExpectedSourceSequence = 0, + })); + } + + [Fact] + public async Task WaitingForWriterLockIsCancellableWithoutACommit() + { + using var directory = new TemporaryDirectory(); + var repository = CreateRepository(directory.Path, lockTimeout: TimeSpan.FromSeconds(2)); + var history = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "session" }); + var lockPath = SessionPath(directory.Path, "session") + ".lck"; + using var held = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(() => + history.AppendEntryAsync("cancelled", "event", "{}", mutationId: "cancelled", cancellationToken: cancellation.Token)); + held.Dispose(); + + var reopened = await CreateRepository(directory.Path).OpenAsync("session"); + Assert.Empty((await reopened.FindEntriesAsync()).Items); + Assert.Empty((await reopened.GetLogAsync()).Items); + } + + [Fact] + public async Task ListAndSearchPaginateAcrossRestart() + { + using var directory = new TemporaryDirectory(); + var repository = CreateRepository(directory.Path); + foreach (var id in new[] { "a", "b", "c" }) + { + var history = await repository.CreateAsync(new GameHistoryCreateOptions { Id = id }); + await history.AppendEntryAsync($"entry-{id}", "world_event", $"{{\"text\":\"needle {id}\"}}"); + } + + var first = await repository.ListAsync(new GameHistoryListQuery { Limit = 2 }); + var second = await repository.ListAsync(new GameHistoryListQuery { Limit = 2, AfterSessionId = first.NextSessionId }); + var search = await CreateRepository(directory.Path).SearchAsync(new GameHistorySearchQuery("needle") { Limit = 2 }); + var continued = await CreateRepository(directory.Path).SearchAsync(new GameHistorySearchQuery("needle") + { + Limit = 2, + Cursor = search.NextCursor, + }); + + Assert.Equal(2, first.Sessions.Count); + Assert.Single(second.Sessions); + Assert.Equal(2, search.Hits.Count); + Assert.Single(continued.Hits); + } + + private static FileGameSessionHistoryRepository CreateRepository(string root, TimeSpan? lockTimeout = null) => + new(new FileGameHistoryOptions(root) + { + LockTimeout = lockTimeout ?? TimeSpan.FromSeconds(5), + LockRetryDelay = TimeSpan.FromMilliseconds(10), + Limits = new GameHistoryLimits + { + MaxSessions = 100, + MaxEntriesPerSession = 1_000, + MaxRecordsPerSession = 1_000, + MaxMutationsPerSession = 3_000, + MaxLanesPerSession = 100, + DefaultQueryResults = 100, + MaxQueryResults = 1_000, + MaxSearchResults = 100, + }, + }); + + private static string SessionPath(string root, string id) => Path.Combine(root, id + ".ogahistory.jsonl"); + + private static async Task<(GameSessionHistory? History, GameHistoryException? Error)> CaptureAsync( + Func> operation) + { + try + { + return (await operation(), null); + } + catch (GameHistoryException exception) + { + return (null, exception); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "oga-history-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/tests/OpenGameAgent.Persistence.Tests/GameResourceFileTests.cs b/tests/OpenGameAgent.Persistence.Tests/GameResourceFileTests.cs new file mode 100644 index 0000000..1fce913 --- /dev/null +++ b/tests/OpenGameAgent.Persistence.Tests/GameResourceFileTests.cs @@ -0,0 +1,287 @@ +using Xunit; + +namespace OpenGameAgent.Persistence.Tests; + +public sealed class GameResourceFileTests +{ + [Fact] + public async Task SkillDiscoveryHonorsIgnoreFilesPreservesSourceAndHidesExplicitOnlySkills() + { + using var directory = new TemporaryDirectory(); + await WriteAsync( + Path.Combine(directory.Path, ".gitignore"), + "*.md\n!visible/SKILL.md\n!explicit/SKILL.md\n!ignored/SKILL.md\n"); + await WriteAsync( + Path.Combine(directory.Path, ".ignore"), + "visible/SKILL.md/\n"); + await WriteAsync( + Path.Combine(directory.Path, ".fdignore"), + "[i]gnored/SKILL.md\n"); + await WriteSkillAsync(directory.Path, "visible", "Visible instructions."); + await WriteSkillAsync( + directory.Path, + "explicit", + "Explicit instructions.", + "disable-model-invocation: true\n"); + await WriteSkillAsync(directory.Path, "ignored", "Ignored instructions."); + var source = new DirectoryGameSkillSource( + directory.Path, + continueOnError: true, + source: "package", + sourceScope: "project"); + + var discovered = source.Discover(); + var selected = await source.SelectAsync( + new GameSkillQuery( + new GameInput("session", "actor", "chat", "{}", new GameMoment("world", 1)), + Array.Empty(), + 10), + TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "explicit", "visible" }, discovered.Skills.Select(skill => skill.SkillId).Order().ToArray()); + var explicitSkill = discovered.Skills.Single(skill => skill.SkillId == "explicit"); + Assert.True(explicitSkill.DisableModelInvocation); + Assert.Equal("package", explicitSkill.SourceInfo!.Source); + Assert.Equal("project", explicitSkill.SourceInfo.Scope); + Assert.Equal(Path.GetFullPath(directory.Path), explicitSkill.SourceInfo.BasePath); + Assert.EndsWith( + Path.Combine("explicit", "SKILL.md"), + explicitSkill.SourceInfo.FilePath, + StringComparison.Ordinal); + Assert.Equal("visible", Assert.Single(selected).SkillId); + Assert.Empty(discovered.Diagnostics); + } + + [Fact] + public void TolerantMissingSkillRootIsEmptyWithoutDiagnostics() + { + using var directory = new TemporaryDirectory(); + var missing = Path.Combine(directory.Path, "missing"); + + var source = new DirectoryGameSkillSource(missing, continueOnError: true); + var result = source.Discover(); + + Assert.Empty(result.Skills); + Assert.Empty(result.Diagnostics); + } + + [Fact] + public async Task LooseRootMarkdownFailureIsDiagnosticWithoutBreakingStrictSkillLoading() + { + using var directory = new TemporaryDirectory(); + await WriteAsync(Path.Combine(directory.Path, "README.md"), "Repository notes only."); + await WriteSkillAsync(directory.Path, "valid", "Valid instructions."); + + var source = new DirectoryGameSkillSource(directory.Path); + var result = source.Discover(); + + Assert.Equal("valid", Assert.Single(result.Skills).SkillId); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Code == GameResourceDiagnosticCodes.InvalidMetadata + && diagnostic.Path.EndsWith("README.md", StringComparison.Ordinal)); + } + + [Fact] + public async Task TolerantSkillDiscoveryContinuesAfterInvalidFilesAndReportsStableDiagnostics() + { + using var directory = new TemporaryDirectory(); + await WriteSkillAsync(directory.Path, "valid", "Valid instructions."); + var brokenDirectory = Path.Combine(directory.Path, "broken"); + Directory.CreateDirectory(brokenDirectory); + await WriteAsync( + Path.Combine(brokenDirectory, "SKILL.md"), + "---\nname: broken\n---\nMissing description."); + var source = new DirectoryGameSkillSource( + directory.Path, + continueOnError: true, + source: "local"); + + var result = source.Discover(); + var selected = await source.SelectAsync( + new GameSkillQuery( + new GameInput("session", "actor", "chat", "{}", new GameMoment("world", 1)), + Array.Empty(), + 10), + TestContext.Current.CancellationToken); + + Assert.Equal("valid", Assert.Single(result.Skills).SkillId); + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("invalid_metadata", diagnostic.Code); + Assert.Equal(GameResourceDiagnosticSeverity.Warning, diagnostic.Severity); + Assert.EndsWith(Path.Combine("broken", "SKILL.md"), diagnostic.Path, StringComparison.Ordinal); + Assert.Equal("local", diagnostic.SourceInfo!.Source); + Assert.Equal("valid", Assert.Single(selected).SkillId); + Assert.Single(source.Diagnostics); + } + + [Fact] + public async Task SkillDiscoveryLoadsOnlyDirectRootMarkdownAndStopsBelowSkillRoots() + { + using var directory = new TemporaryDirectory(); + await WriteAsync( + Path.Combine(directory.Path, "root-skill.md"), + "---\nname: root-skill\ndescription: Root skill.\n---\nRoot instructions."); + var nestedLooseDirectory = Path.Combine(directory.Path, "loose"); + Directory.CreateDirectory(nestedLooseDirectory); + await WriteAsync( + Path.Combine(nestedLooseDirectory, "ignored.md"), + "---\nname: ignored\ndescription: Nested loose markdown.\n---\nIgnored."); + await WriteSkillAsync(directory.Path, "parent", "Parent instructions."); + await WriteSkillAsync( + Path.Combine(directory.Path, "parent"), + "child", + "Child instructions."); + var source = new DirectoryGameSkillSource(directory.Path, continueOnError: true); + + var result = source.Discover(); + + Assert.Equal(new[] { "parent", "root-skill" }, result.Skills.Select(skill => skill.SkillId).Order().ToArray()); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Code == "invalid_metadata" + && diagnostic.Path.EndsWith("root-skill.md", StringComparison.Ordinal)); + } + + [Fact] + public async Task PromptTemplateLoaderLoadsDirectFilesMetadataArgumentsAndDiagnostics() + { + using var directory = new TemporaryDirectory(); + var firstDirectory = Path.Combine(directory.Path, "first"); + var secondDirectory = Path.Combine(directory.Path, "second"); + Directory.CreateDirectory(Path.Combine(firstDirectory, "nested")); + Directory.CreateDirectory(secondDirectory); + await WriteAsync( + Path.Combine(firstDirectory, "one.md"), + "---\ndescription: One template\nargument-hint: \n---\nHello $1, pursue ${@:2}."); + await WriteAsync( + Path.Combine(firstDirectory, "nested", "ignored.md"), + "Ignored nested template."); + await WriteAsync( + Path.Combine(secondDirectory, "two.md"), + "First line description\nBody $ARGUMENTS"); + var broken = Path.Combine(directory.Path, "broken.md"); + await WriteAsync( + broken, + "---\ndescription: 'unterminated\n---\nBroken"); + var loader = new FileGamePromptTemplateLoader( + new[] { firstDirectory, secondDirectory, broken, Path.Combine(directory.Path, "missing") }, + source: "package", + sourceScope: "project"); + + var result = loader.Load(); + + Assert.Equal(new[] { "one", "two" }, result.PromptTemplates.Select(template => template.Name).ToArray()); + var one = result.PromptTemplates[0]; + Assert.Equal("One template", one.Description); + Assert.Equal(" ", one.ArgumentHint); + Assert.Equal("package", one.SourceInfo!.Source); + Assert.Equal(firstDirectory, one.SourceInfo.BasePath); + Assert.Equal( + "Hello Mira, pursue restore village.", + GamePromptTemplateFormatter.Format(one, new[] { "Mira", "restore", "village" })); + Assert.Equal("First line description", result.PromptTemplates[1].Description); + Assert.Equal("parse_failed", Assert.Single(result.Diagnostics).Code); + } + + [Fact] + public async Task FileResourceDiagnosticsAreBoundedByCountAndMessageLength() + { + using var directory = new TemporaryDirectory(); + for (var index = 0; index < 3; index++) + { + var skillDirectory = Path.Combine(directory.Path, $"broken-{index}"); + Directory.CreateDirectory(skillDirectory); + await WriteAsync( + Path.Combine(skillDirectory, "SKILL.md"), + "---\nname: 'unterminated\ndescription: Broken skill.\n---\nBroken"); + } + + var source = new DirectoryGameSkillSource( + directory.Path, + continueOnError: true, + maximumDiagnostics: 2, + maximumDiagnosticCharacters: 16); + + var skillResult = source.Discover(); + + Assert.Equal(2, skillResult.Diagnostics.Count); + Assert.All(skillResult.Diagnostics, diagnostic => Assert.True(diagnostic.Message.Length <= 16)); + + var templatePaths = Enumerable.Range(0, 3) + .Select(index => Path.Combine(directory.Path, $"template-{index}.md")) + .ToArray(); + foreach (var path in templatePaths) + { + await WriteAsync(path, "---\ndescription: 'unterminated\n---\nBroken"); + } + + var loader = new FileGamePromptTemplateLoader( + templatePaths, + maximumDiagnostics: 2, + maximumDiagnosticCharacters: 16); + + var templateResult = loader.Load(); + + Assert.Equal(2, templateResult.Diagnostics.Count); + Assert.All(templateResult.Diagnostics, diagnostic => Assert.True(diagnostic.Message.Length <= 16)); + } + + [Fact] + public async Task PromptTemplateFrontMatterRequiresAnExactClosingDelimiter() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "literal.md"); + await WriteAsync( + path, + "---\ndescription: Metadata should remain literal.\n---not-a-delimiter\nBody"); + + var result = new FileGamePromptTemplateLoader(path).Load(); + + var template = Assert.Single(result.PromptTemplates); + Assert.Empty(result.Diagnostics); + Assert.StartsWith("---\n", template.Content, StringComparison.Ordinal); + Assert.Equal("---", template.Description); + } + + private static async Task WriteSkillAsync( + string root, + string name, + string instructions, + string extraFrontMatter = "") + { + var directory = Path.Combine(root, name); + Directory.CreateDirectory(directory); + await WriteAsync( + Path.Combine(directory, "SKILL.md"), + $"---\nname: {name}\ndescription: {name} skill.\n{extraFrontMatter}---\n{instructions}"); + } + + private static Task WriteAsync(string path, string content) => + File.WriteAllTextAsync(path, content, TestContext.Current.CancellationToken); + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "oga-resource-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } +} diff --git a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs index ae12731..7086407 100644 --- a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs @@ -65,6 +65,218 @@ public async Task SessionRoundTripsEveryCanonicalContentKindAcrossRestart() Assert.Equal(12, loaded.LastMoment!.Value.Tick); } + [Fact] + public async Task SessionUsageLedgerAndCostsSurviveRestart() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("session", "actor"); + var records = new[] + { + new GameSessionUsageRecord( + "run-one-assistant", + GameSessionUsageCause.Assistant, + new ModelUsage( + 12, + 4, + 3, + 2, + reasoningTokens: 2, + cacheWriteOneHourTokens: 1, + cost: new ModelCost(0.12, 0.08, 0.003, 0.004)), + "run-one", + "input-one"), + new GameSessionUsageRecord( + "run-one-compaction", + GameSessionUsageCause.Compaction, + new ModelUsage(6, 2, cost: new ModelCost(0.06, 0.04)), + "run-one", + "input-one", + "{\"removed\":8}"), + }; + var store = new FileGameSessionStore(directory.Path); + await store.SaveAsync( + new GameSessionSnapshot( + key, + 1, + usageLedger: new GameSessionUsageLedger(records)), + 0, + TestContext.Current.CancellationToken); + + var restarted = new FileGameSessionStore(directory.Path); + var loaded = await restarted.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.Equal(2, loaded.UsageLedger.Records.Count); + Assert.Equal(29, loaded.UsageLedger.Stats.TotalTokens); + Assert.Equal(8, loaded.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + Assert.Equal(0.307, loaded.UsageLedger.Stats.CostTotal, precision: 10); + Assert.Equal("{\"removed\":8}", loaded.UsageLedger.Records[1].DetailsJson); + Assert.Equal(2, loaded.UsageLedger.Records[0].Usage.ReasoningTokens); + Assert.Equal(1, loaded.UsageLedger.Records[0].Usage.CacheWriteOneHourTokens); + var file = Assert.Single(Directory.GetFiles(directory.Path, "*.session.json")); + Assert.Equal(3, JsonNode.Parse(await File.ReadAllTextAsync( + file, + TestContext.Current.CancellationToken))!["FormatVersion"]!.GetValue()); + } + + [Fact] + public async Task BoundedUsageLedgerTotalsSurviveEvictionAndRestart() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("session", "actor"); + var records = Enumerable.Range(0, 1_000) + .Select(index => new GameSessionUsageRecord( + "record-" + index, + index % 2 == 0 ? GameSessionUsageCause.Assistant : GameSessionUsageCause.Compaction, + new ModelUsage(2, 1, cost: new ModelCost(input: 0.02, output: 0.01)))) + .ToArray(); + var store = new FileGameSessionStore(directory.Path); + await store.SaveAsync( + new GameSessionSnapshot( + key, + 1, + usageLedger: new GameSessionUsageLedger(records, recentRecordCapacity: 3)), + 0, + TestContext.Current.CancellationToken); + + var restarted = new FileGameSessionStore(directory.Path); + var loaded = await restarted.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.Equal(3, loaded.UsageLedger.Records.Count); + Assert.Equal(1_000, loaded.UsageLedger.TotalRecordCount); + Assert.Equal(3_000, loaded.UsageLedger.Stats.TotalTokens); + Assert.Equal(30, loaded.UsageLedger.Stats.CostTotal, precision: 10); + Assert.Equal(1_500, loaded.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + Assert.Equal(1_500, loaded.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + Assert.Equal(new[] { "record-997", "record-998", "record-999" }, + loaded.UsageLedger.Records.Select(record => record.RecordId)); + Assert.True(new FileInfo(Assert.Single(Directory.GetFiles(directory.Path, "*.session.json"))).Length < 16_384); + + var truncatedTotals = GameSessionUsageLedger.Restore( + loaded.UsageLedger.Records, + new Dictionary + { + [GameSessionUsageCause.Assistant] = new GameSessionUsageTotals( + 2, 1, 0, 0, 0, 0, 0.02, 0.01, 0, 0), + [GameSessionUsageCause.Compaction] = new GameSessionUsageTotals( + 4, 2, 0, 0, 0, 0, 0.04, 0.02, 0, 0), + }, + loaded.UsageLedger.TotalRecordCount, + loaded.UsageLedger.RecentRecordCapacity); + await Assert.ThrowsAsync(async () => + await restarted.SaveAsync( + new GameSessionSnapshot(key, 2, usageLedger: truncatedTotals), + 1, + TestContext.Current.CancellationToken)); + + var appended = loaded.UsageLedger.Append(new[] + { + new GameSessionUsageRecord( + "record-1000", + GameSessionUsageCause.Tool, + new ModelUsage(4, 2)), + }); + Assert.True((await restarted.SaveAsync( + new GameSessionSnapshot(key, 2, usageLedger: appended), + 1, + TestContext.Current.CancellationToken)).Saved); + var final = await new FileGameSessionStore(directory.Path) + .LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(final); + Assert.Equal(3, final.UsageLedger.Records.Count); + Assert.Equal(1_001, final.UsageLedger.TotalRecordCount); + Assert.Equal(3_006, final.UsageLedger.Stats.TotalTokens); + Assert.Equal(new[] { "record-998", "record-999", "record-1000" }, + final.UsageLedger.Records.Select(record => record.RecordId)); + } + + [Fact] + public async Task VersionTwoSessionMigratesToAnEmptyLedgerAndCanUpgrade() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("session", "actor"); + var store = new FileGameSessionStore(directory.Path); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new[] { AgentMessage.User("legacy") }), + 0, + TestContext.Current.CancellationToken); + var file = Assert.Single(Directory.GetFiles(directory.Path, "*.session.json")); + var document = JsonNode.Parse(await File.ReadAllTextAsync( + file, + TestContext.Current.CancellationToken))!.AsObject(); + document["FormatVersion"] = 2; + Assert.True(document.Remove("UsageRecords")); + await File.WriteAllTextAsync(file, document.ToJsonString(), TestContext.Current.CancellationToken); + + var restarted = new FileGameSessionStore(directory.Path); + var legacy = await restarted.LoadAsync(key, TestContext.Current.CancellationToken); + Assert.NotNull(legacy); + Assert.Empty(legacy.UsageLedger.Records); + + var record = new GameSessionUsageRecord( + "upgraded-usage", + GameSessionUsageCause.Assistant, + new ModelUsage(2, 1)); + Assert.True((await restarted.SaveAsync( + new GameSessionSnapshot( + key, + 2, + legacy.Messages, + usageLedger: legacy.UsageLedger.Append(new[] { record })), + 1, + TestContext.Current.CancellationToken)).Saved); + var upgraded = await new FileGameSessionStore(directory.Path) + .LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(upgraded); + Assert.Single(upgraded.UsageLedger.Records); + Assert.Equal(3, upgraded.UsageLedger.Stats.TotalTokens); + } + + [Fact] + public async Task SessionStoreRejectsUsageLedgerRemovalOrRewrite() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("session", "actor"); + var record = new GameSessionUsageRecord( + "stable-usage", + GameSessionUsageCause.Assistant, + new ModelUsage(2, 1)); + var store = new FileGameSessionStore(directory.Path); + await store.SaveAsync( + new GameSessionSnapshot( + key, + 1, + usageLedger: new GameSessionUsageLedger(new[] { record })), + 0, + TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(async () => + await store.SaveAsync( + new GameSessionSnapshot(key, 2), + 1, + TestContext.Current.CancellationToken)); + var rewritten = new GameSessionUsageRecord( + record.RecordId, + record.Cause, + new ModelUsage(9, 9)); + await Assert.ThrowsAsync(async () => + await store.SaveAsync( + new GameSessionSnapshot( + key, + 2, + usageLedger: new GameSessionUsageLedger(new[] { rewritten })), + 1, + TestContext.Current.CancellationToken)); + + var loaded = await store.LoadAsync(key, TestContext.Current.CancellationToken); + Assert.NotNull(loaded); + Assert.Equal(1, loaded.Revision); + Assert.Equal(3, loaded.UsageLedger.Stats.TotalTokens); + } + [Fact] public async Task SessionSaveUsesOptimisticRevisionAfterRestart() { diff --git a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json index 9cf1db8..34cf43d 100644 --- a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json @@ -212,7 +212,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -221,6 +222,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, "opengameagent.persistence": { "type": "Project", "dependencies": { diff --git a/tests/OpenGameAgent.ProviderTransport.Tests/OpenGameAgent.ProviderTransport.Tests.csproj b/tests/OpenGameAgent.ProviderTransport.Tests/OpenGameAgent.ProviderTransport.Tests.csproj new file mode 100644 index 0000000..ed50340 --- /dev/null +++ b/tests/OpenGameAgent.ProviderTransport.Tests/OpenGameAgent.ProviderTransport.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.ProviderTransport.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.ProviderTransport.Tests/ProviderTransportTests.cs b/tests/OpenGameAgent.ProviderTransport.Tests/ProviderTransportTests.cs new file mode 100644 index 0000000..e3366bc --- /dev/null +++ b/tests/OpenGameAgent.ProviderTransport.Tests/ProviderTransportTests.cs @@ -0,0 +1,378 @@ +using System.Net; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.ProviderTransport.Tests; + +public sealed class ProviderTransportTests +{ + [Fact] + public void HeaderGuardEnforcesCountNameAndValueBounds() + { + ProviderHeaderGuard.Validate( + new Dictionary { ["x-safe"] = "value" }, + "headers"); + + Assert.Throws(() => ProviderHeaderGuard.Validate( + Enumerable.Range(0, 65).Select(index => new KeyValuePair("x-" + index, "value")), + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["bad header"] = "value" }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["x-safe"] = "value\r\nforged" }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["x-safe"] = new string('v', 65_537) }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["Host"] = "example.test" }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["Content-Length"] = "1" }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["Transfer-Encoding"] = "chunked" }, + "headers")); + Assert.Throws(() => ProviderHeaderGuard.Validate( + new Dictionary { ["Sec-WebSocket-Key"] = "secret" }, + "headers")); + + ProviderHeaderGuard.ValidateMerge( + new Dictionary { ["x-optional"] = null }, + "headers"); + } + + [Fact] + public void ObservationOnlyIncludesBoundedAllowlistedResponseMetadata() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.TryAddWithoutValidation("x-request-id", "request-1\r\nforged"); + response.Headers.TryAddWithoutValidation("x-ratelimit-remaining-tokens", new string('7', 2_000)); + response.Headers.TryAddWithoutValidation("set-cookie", "credential=secret"); + response.Headers.TryAddWithoutValidation("authorization", "Bearer secret"); + response.Headers.TryAddWithoutValidation("x-private-gateway", "internal"); + + var observation = ProviderResponseObservation.FromHttpResponse( + "provider", + "api", + "model", + response); + + Assert.Equal(429, observation.StatusCode); + Assert.Equal("request-1 forged", observation.Metadata["x-request-id"]); + Assert.Equal(1_024, observation.Metadata["x-ratelimit-remaining-tokens"].Length); + Assert.DoesNotContain(observation.Metadata.Keys, key => key.Contains("cookie", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(observation.Metadata.Values, value => value.Contains("secret", StringComparison.Ordinal)); + Assert.DoesNotContain("x-private-gateway", observation.Metadata.Keys); + } + + [Fact] + public void ProviderObservationOnlyExposesBoundedRequestId() + { + var observation = ProviderResponseObservation.FromProviderResponse( + "amazon-bedrock", + "bedrock-converse-stream", + "model", + 503, + new string('r', 2_000)); + + Assert.Equal(1_024, observation.Metadata["request-id"].Length); + } + + [Fact] + public void ResponseMetadataObservationFiltersArbitraryWebSocketHeaders() + { + var observation = ProviderResponseObservation.FromResponseMetadata( + "openai", + "openai-responses", + "model", + 101, + new Dictionary + { + ["x-request-id"] = "request-1\r\nforged", + ["set-cookie"] = "secret=value", + ["authorization"] = "Bearer secret", + }); + + Assert.Equal("request-1 forged", observation.Metadata["x-request-id"]); + Assert.Single(observation.Metadata); + } + + [Fact] + public void HostileResponseMetadataEnumeratorIsIsolated() + { + var observation = ProviderResponseObservation.FromResponseMetadata( + "provider", + "api", + "model", + 101, + new ThrowingHeaders()); + + Assert.Empty(observation.Metadata); + } + + [Fact] + public async Task ObserverFailureIsIsolated() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + + var outcome = await ProviderResponseObserverRunner.NotifyAsync( + (_, _) => throw new InvalidOperationException("credential should not escape"), + observation, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ProviderResponseObserverOutcome.Failed, outcome); + } + + [Fact] + public async Task ObserverTimeoutCancelsAndReturnsWithoutWaitingForBadCallback() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var outcome = await ProviderResponseObserverRunner.NotifyAsync( + async (_, token) => + { + token.Register(() => cancellationSeen.TrySetResult(null)); + await release.Task.ConfigureAwait(false); + }, + observation, + timeoutMilliseconds: 20, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ProviderResponseObserverOutcome.TimedOut, outcome); + await cancellationSeen.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + release.TrySetResult(null); + } + + [Fact] + public async Task CallerCancellationInterruptsObserver() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(20); + + await Assert.ThrowsAnyAsync(async () => + await ProviderResponseObserverRunner.NotifyAsync( + async (_, token) => await Task.Delay(Timeout.Infinite, token), + observation, + timeoutMilliseconds: 10_000, + cancellationToken: cancellation.Token)); + } + + [Fact] + public async Task PermanentObserverIsInvokedOnceAndThenSuppressed() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + var invoked = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ProviderResponseObserver observer = async (_, _) => + { + try + { + Interlocked.Increment(ref invoked); + await release.Task.ConfigureAwait(false); + } + finally + { + completed.TrySetResult(null); + } + }; + + Assert.Equal( + ProviderResponseObserverOutcome.TimedOut, + await ProviderResponseObserverRunner.NotifyAsync( + observer, + observation, + 5, + TestContext.Current.CancellationToken)); + for (var index = 0; index < 1_000; index++) + { + Assert.Equal( + ProviderResponseObserverOutcome.Suppressed, + await ProviderResponseObserverRunner.NotifyAsync( + observer, + observation, + 5, + TestContext.Current.CancellationToken)); + } + + Assert.Equal(1, Volatile.Read(ref invoked)); + release.TrySetResult(null); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DistinctPermanentObserversHaveAGlobalInflightBound() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completedCount = 0; + var observers = Enumerable.Range(0, ProviderResponseObserverRunner.MaximumConcurrentObservers + 1) + .Select(index => (ProviderResponseObserver)(async (_, _) => + { + _ = index; + try + { + await release.Task.ConfigureAwait(false); + } + finally + { + if (Interlocked.Increment(ref completedCount) + == ProviderResponseObserverRunner.MaximumConcurrentObservers) + { + completed.TrySetResult(null); + } + } + })) + .ToArray(); + var outcomes = new List(); + + foreach (var observer in observers) + { + outcomes.Add(await ProviderResponseObserverRunner.NotifyAsync( + observer, + observation, + 5, + TestContext.Current.CancellationToken)); + } + + Assert.Equal(ProviderResponseObserverRunner.MaximumConcurrentObservers, outcomes.Count(value => + value == ProviderResponseObserverOutcome.TimedOut)); + Assert.Single(outcomes, value => value == ProviderResponseObserverOutcome.Suppressed); + release.TrySetResult(null); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CompletedObserverCanBeReusedWithoutCallerTokenRegistrationsAccumulating() + { + var observation = ProviderResponseObservation.FromProviderResponse("provider", "api", "model", 200); + var invoked = 0; + ProviderResponseObserver observer = (_, _) => + { + Interlocked.Increment(ref invoked); + return ValueTask.CompletedTask; + }; + using var caller = new CancellationTokenSource(); + + for (var index = 0; index < 1_000; index++) + { + Assert.Equal( + ProviderResponseObserverOutcome.Completed, + await ProviderResponseObserverRunner.NotifyAsync(observer, observation, cancellationToken: caller.Token)); + } + + caller.Cancel(); + Assert.Equal(1_000, Volatile.Read(ref invoked)); + } + + [Fact] + public async Task CallbackCancellationReturnsBeforeNonCooperativeCallbackAndObservesLateFault() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var operation = ProviderCallbackRunner.RunAsync( + async _ => + { + await release.Task.ConfigureAwait(false); + throw new InvalidOperationException("late"); + }, + cancellation.Token); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await operation); + release.TrySetResult(null); + } + + [Theory] + [InlineData(408, true)] + [InlineData(409, true)] + [InlineData(429, true)] + [InlineData(500, true)] + [InlineData(400, false)] + public void RetryMetadataClassifiesStatus(int statusCode, bool expected) + { + using var response = new HttpResponseMessage((HttpStatusCode)statusCode); + + Assert.Equal(expected, ProviderHttpRetryMetadata.FromResponse(response).IsTransient); + } + + [Fact] + public void RetryDirectiveOverridesStatusAndRetryAfterDateIsParsed() + { + var now = new DateTimeOffset(2026, 8, 8, 0, 0, 0, TimeSpan.Zero); + using var retry = new HttpResponseMessage(HttpStatusCode.BadRequest); + retry.Headers.TryAddWithoutValidation("x-should-retry", "true"); + retry.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(now.AddSeconds(3)); + using var noRetry = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + noRetry.Headers.TryAddWithoutValidation("x-should-retry", "false"); + + var metadata = ProviderHttpRetryMetadata.FromResponse(retry, now); + + Assert.True(metadata.IsTransient); + Assert.Equal(TimeSpan.FromSeconds(3), metadata.RetryAfter); + Assert.False(ProviderHttpRetryMetadata.FromResponse(noRetry).IsTransient); + } + + [Fact] + public void RetryAfterMillisecondsIsClampedAndProviderDecisionCanOverrideStatus() + { + using var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.TryAddWithoutValidation("retry-after-ms", "1e100"); + + var responseMetadata = ProviderHttpRetryMetadata.FromResponse(response); + Assert.Equal(TimeSpan.MaxValue, responseMetadata.RetryAfter); + Assert.False(responseMetadata.IsTransient); + Assert.False(ProviderHttpRetryMetadata.FromStatus(503, providerRetryable: false).IsTransient); + Assert.True(ProviderHttpRetryMetadata.FromStatus(null).IsTransient); + } + + [Theory] + [InlineData("insufficient_quota")] + [InlineData("Monthly usage limit reached; enable available balance")] + [InlineData("billing account disabled")] + public void QuotaAndBillingRateLimitsAreTerminal(string errorText) + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.TryAddWithoutValidation("x-should-retry", "true"); + + Assert.False(ProviderHttpRetryMetadata.FromResponse(response, errorText: errorText).IsTransient); + } + + [Fact] + public void OrdinaryRateLimitRemainsTransient() + { + using var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + + Assert.True(ProviderHttpRetryMetadata.FromResponse(response, errorText: "rate limit exceeded").IsTransient); + } + + private sealed class ThrowingHeaders : IReadOnlyDictionary + { + public int Count => 1; + + public IEnumerable Keys => throw new InvalidOperationException("hostile metadata"); + + public IEnumerable Values => throw new InvalidOperationException("hostile metadata"); + + public string this[string key] => throw new KeyNotFoundException(); + + public bool ContainsKey(string key) => false; + + public IEnumerator> GetEnumerator() => + throw new InvalidOperationException("hostile metadata"); + + public bool TryGetValue(string key, out string value) + { + value = string.Empty; + return false; + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/tests/OpenGameAgent.ProviderTransport.Tests/packages.lock.json b/tests/OpenGameAgent.ProviderTransport.Tests/packages.lock.json new file mode 100644 index 0000000..aae18e4 --- /dev/null +++ b/tests/OpenGameAgent.ProviderTransport.Tests/packages.lock.json @@ -0,0 +1,205 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.Anthropic.Tests/AnthropicMessagesProviderTests.cs b/tests/OpenGameAgent.Providers.Anthropic.Tests/AnthropicMessagesProviderTests.cs new file mode 100644 index 0000000..5c24fd9 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Anthropic.Tests/AnthropicMessagesProviderTests.cs @@ -0,0 +1,366 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.Anthropic.Tests; + +public sealed class AnthropicMessagesProviderTests +{ + [Fact] + public async Task StreamsThinkingTextToolCallsAndDetailedUsage() + { + const string stream = """ + event: message_start + data: {"type":"message_start","message":{"id":"msg_1","model":"served-model","usage":{"input_tokens":10,"output_tokens":1,"cache_read_input_tokens":2,"cache_creation_input_tokens":3,"cache_creation":{"ephemeral_1h_input_tokens":1}}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"plan"}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"opaque"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: content_block_start + data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"hello"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":1} + + event: content_block_start + data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"tool_1","name":"move","input":{}}} + + event: content_block_delta + data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"x\":1}"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":2} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":4,"output_tokens_details":{"thinking_tokens":1}}} + + event: message_stop + data: {"type":"message_stop"} + + """; + var provider = Create(new StubHandler(_ => Response(stream))); + + var events = await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("msg_1", response.ResponseId); + Assert.Equal("served-model", response.ResponseModel); + Assert.Equal("tool_use", response.RawStopReason); + Assert.Equal("plan", Assert.IsType(response.Content[0]).Text); + Assert.Equal("opaque", Assert.IsType(response.Content[0]).Signature); + Assert.Equal("hello", Assert.IsType(response.Content[1]).Text); + Assert.Equal("{\"x\":1}", Assert.IsType(response.Content[2]).ArgumentsJson); + Assert.Equal(10, response.Usage.InputTokens); + Assert.Equal(2, response.Usage.CacheReadTokens); + Assert.Equal(3, response.Usage.CacheWriteTokens); + Assert.Equal(1, response.Usage.CacheWriteOneHourTokens); + Assert.Equal(1, response.Usage.ReasoningTokens); + + var terminalReasoning = Assert.IsType(response.Content[0]); + var reasoningEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded); + Assert.Equal(terminalReasoning.Text, reasoningEnded.Content); + var endedReasoning = Assert.IsType(reasoningEnded.Partial!.Content[reasoningEnded.ContentIndex]); + Assert.Equal(terminalReasoning.Text, endedReasoning.Text); + Assert.Equal(terminalReasoning.Signature, endedReasoning.Signature); + + var textEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.TextEnded); + Assert.Equal(Assert.IsType(response.Content[1]).Text, textEnded.Content); + Assert.Equal( + Assert.IsType(response.Content[1]).Text, + Assert.IsType(textEnded.Partial!.Content[textEnded.ContentIndex]).Text); + + var toolStarted = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallStarted); + var toolDeltas = events.Where(item => item.Kind == ModelStreamEventKind.ToolCallDelta).ToArray(); + Assert.NotEmpty(toolDeltas); + var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + var toolEvents = events.Where(item => item.Kind is + ModelStreamEventKind.ToolCallStarted or + ModelStreamEventKind.ToolCallDelta or + ModelStreamEventKind.ToolCallEnded); + Assert.All(toolEvents, item => + { + Assert.Equal(toolStarted.ContentIndex, item.ContentIndex); + var partialToolCall = Assert.IsType(item.Partial!.Content[item.ContentIndex]); + AssertJsonObject(partialToolCall.ArgumentsJson); + }); + + var terminalToolCall = Assert.IsType(response.Content[2]); + var endedToolCall = Assert.IsType(toolEnded.ToolCall); + var endedPartialToolCall = Assert.IsType(toolEnded.Partial!.Content[toolEnded.ContentIndex]); + Assert.Equal(terminalToolCall.Id, endedToolCall.Id); + Assert.Equal(terminalToolCall.Name, endedToolCall.Name); + Assert.Equal("{\"x\":1}", endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ArgumentsJson, endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ThoughtSignature, endedToolCall.ThoughtSignature); + Assert.Equal(terminalToolCall.Namespace, endedToolCall.Namespace); + Assert.Equal(endedToolCall.Id, toolEnded.ToolCallId); + Assert.Equal(endedToolCall.Name, toolEnded.ToolName); + AssertToolCallEqual(endedToolCall, endedPartialToolCall); + } + + [Fact] + public async Task SerializesCacheAdaptiveThinkingImagesStrictAndReferencedTools() + { + var handler = new StubHandler(_ => Response(""" + event: message_start + data: {"type":"message_start","message":{"id":"msg_1","model":"model","usage":{"input_tokens":0,"output_tokens":0}}} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":0}} + + event: message_stop + data: {"type":"message_stop"} + + """)); + var options = Options(new HttpClient(handler)); + options.SupportsToolReferences = true; + options.SupportsStrictTools = true; + options.ForceAdaptiveThinking = true; + var provider = new AnthropicMessagesProvider(options); + var inspect = new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}"); + var move = new ToolDefinition( + "move", + "Move", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}},\"required\":[\"x\"]}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var call = new ToolCallContent("tool_1", "inspect", "{}"); + var request = new ModelRequest( + "model", + "rules", + new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse, + provider: "anthropic", + api: "anthropic-messages"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] { new TextContent("clear") }, addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch), + }, + new[] { inspect, move }, + new ModelParameters + { + ReasoningLevel = "high", + CacheRetention = ModelCacheRetention.Long, + }, + "session", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.Equal("adaptive", root.GetProperty("thinking").GetProperty("type").GetString()); + Assert.Equal("high", root.GetProperty("output_config").GetProperty("effort").GetString()); + Assert.Equal("1h", root.GetProperty("system")[0].GetProperty("cache_control").GetProperty("ttl").GetString()); + Assert.Contains("aW1hZ2U=", handler.RequestBody, StringComparison.Ordinal); + var tools = root.GetProperty("tools"); + Assert.False(tools[0].TryGetProperty("defer_loading", out _)); + Assert.True(tools[1].GetProperty("strict").GetBoolean()); + Assert.True(tools[1].GetProperty("defer_loading").GetBoolean()); + Assert.Contains("tool_reference", handler.RequestBody, StringComparison.Ordinal); + } + + [Fact] + public async Task RejectsMismatchedSseAndJsonEventTypes() + { + var provider = Create(new StubHandler(_ => Response(""" + event: message_start + data: {"type":"message_stop"} + + """))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + Assert.Contains("does not match", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task RejectsStreamWithoutMessageStop() + { + var provider = Create(new StubHandler(_ => Response(""" + event: message_start + data: {"type":"message_start","message":{"id":"msg_1","model":"model","usage":{"input_tokens":0,"output_tokens":0}}} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":0}} + + """))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + Assert.Contains("message_stop", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ObservesSanitizedFailureMetadataAndHonorsRetryDirective() + { + ProviderResponseObservation? observed = null; + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("rate limited", Encoding.UTF8, "text/plain"), + }; + response.Headers.TryAddWithoutValidation("x-request-id", "request-1"); + response.Headers.TryAddWithoutValidation("x-should-retry", "false"); + response.Headers.TryAddWithoutValidation("retry-after-ms", "2000"); + response.Headers.TryAddWithoutValidation("set-cookie", "secret=value"); + var options = Options(new HttpClient(new StubHandler(_ => response))); + options.ResponseObserver = (value, _) => + { + observed = value; + return default; + }; + var provider = new AnthropicMessagesProvider(options); + + var failure = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + + Assert.NotNull(observed); + Assert.Equal(429, observed!.StatusCode); + Assert.Equal("request-1", observed.Metadata["x-request-id"]); + Assert.DoesNotContain(observed.Metadata.Keys, key => key.Contains("cookie", StringComparison.OrdinalIgnoreCase)); + Assert.False(failure.IsTransient); + Assert.Equal(TimeSpan.FromSeconds(2), failure.RetryAfter); + Assert.Equal(429, failure.StatusCode); + } + + [Fact] + public async Task TombstoneSuppressesOptionalAffinityButCannotDeleteRequiredVersionOrCredential() + { + var handler = new StubHandler(_ => Response(""" + event: message_start + data: {"type":"message_start","message":{"id":"msg_1","model":"model","usage":{"input_tokens":0,"output_tokens":0}}} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":0}} + + event: message_stop + data: {"type":"message_stop"} + + """)); + var options = Options(new HttpClient(handler)); + options.ApiKey = "test-key"; + options.SendSessionAffinityHeaders = true; + options.Headers["x-session-affinity"] = null; + options.Headers["anthropic-version"] = null; + options.Headers["x-api-key"] = null; + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + Array.Empty(), + new ModelParameters { CacheRetention = ModelCacheRetention.Short }, + "session", + "run", + 1); + + await CollectAsync(new AnthropicMessagesProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken)); + + Assert.Null(handler.Header("x-session-affinity")); + Assert.Equal(options.ApiVersion, handler.Header("anthropic-version")); + Assert.Equal("test-key", handler.Header("x-api-key")); + } + + private static AnthropicMessagesProvider Create(HttpMessageHandler handler) => + new(Options(new HttpClient(handler))); + + private static AnthropicMessagesProviderOptions Options(HttpClient client) => + new(client, new Uri("https://api.example.test/v1/messages")); + + private static ModelRequest Request() => + new("model", "rules", Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); + + private static HttpResponseMessage Response(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }; + + private static async Task> CollectAsync(IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var item in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + events.Add(item); + } + + return events; + } + + private static void AssertJsonObject(string value) + { + using var document = JsonDocument.Parse(value); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + } + + private static void AssertToolCallEqual(ToolCallContent expected, ToolCallContent actual) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.ArgumentsJson, actual.ArgumentsJson); + Assert.Equal(expected.ThoughtSignature, actual.ThoughtSignature); + Assert.Equal(expected.Namespace, actual.Namespace); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _response; + + public StubHandler(Func response) + { + _response = response; + } + + public string? RequestBody { get; private set; } + + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + public string? Header(string name) => _headers.TryGetValue(name, out var value) ? value : null; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + foreach (var header in request.Headers) + { + _headers[header.Key] = string.Join(",", header.Value); + } + + return _response(request); + } + } +} diff --git a/tests/OpenGameAgent.Providers.Anthropic.Tests/OpenGameAgent.Providers.Anthropic.Tests.csproj b/tests/OpenGameAgent.Providers.Anthropic.Tests/OpenGameAgent.Providers.Anthropic.Tests.csproj new file mode 100644 index 0000000..8969c3e --- /dev/null +++ b/tests/OpenGameAgent.Providers.Anthropic.Tests/OpenGameAgent.Providers.Anthropic.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.Anthropic.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json new file mode 100644 index 0000000..17c81c1 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json @@ -0,0 +1,224 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.anthropic": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.Bedrock.Tests/BedrockConverseProviderTests.cs b/tests/OpenGameAgent.Providers.Bedrock.Tests/BedrockConverseProviderTests.cs new file mode 100644 index 0000000..8986b95 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Bedrock.Tests/BedrockConverseProviderTests.cs @@ -0,0 +1,622 @@ +using System.Net; +using System.Text.Json; +using Amazon.BedrockRuntime; +using Amazon.BedrockRuntime.Model; +using Amazon.Runtime; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.Bedrock.Tests; + +public sealed class BedrockConverseProviderTests +{ + [Fact] + public void CustomServiceUrlsRequireSafeUrisOrExplicitInsecureOptIn() + { + Assert.Throws(() => new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ServiceUrl = "file:///tmp/bedrock", + })); + Assert.Throws(() => new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ServiceUrl = "https://user:secret@bedrock.example/v1", + })); + Assert.Throws(() => new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ServiceUrl = "http://bedrock.example/v1", + })); + + _ = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ServiceUrl = "http://bedrock.example/v1", + AllowInsecureHttp = true, + }); + _ = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ServiceUrl = "http://127.0.0.1:4566", + }); + } + + [Fact] + public async Task StreamsReasoningTextToolCallsAndUsage() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (_, token) => SuccessfulStream(token), + }); + + var events = await CollectAsync(provider.StreamAsync(Request("anthropic.claude-sonnet-4-5"), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("tool_use", response.RawStopReason); + var reasoning = Assert.IsType(response.Content[0]); + Assert.Equal("plan", reasoning.Text); + Assert.Equal("signature", reasoning.Signature); + Assert.Equal("hello", Assert.IsType(response.Content[1]).Text); + var tool = Assert.IsType(response.Content[2]); + Assert.Equal("tool-1", tool.Id); + Assert.Equal("{\"x\":1}", tool.ArgumentsJson); + Assert.Equal(10, response.Usage.InputTokens); + Assert.Equal(2, response.Usage.OutputTokens); + Assert.Equal(3, response.Usage.CacheReadTokens); + Assert.Equal(4, response.Usage.CacheWriteTokens); + + var reasoningEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded); + Assert.Equal(reasoning.Text, reasoningEnded.Content); + var endedReasoning = Assert.IsType(reasoningEnded.Partial!.Content[reasoningEnded.ContentIndex]); + Assert.Equal(reasoning.Text, endedReasoning.Text); + Assert.Equal(reasoning.Signature, endedReasoning.Signature); + + var textEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.TextEnded); + Assert.Equal(Assert.IsType(response.Content[1]).Text, textEnded.Content); + Assert.Equal( + Assert.IsType(response.Content[1]).Text, + Assert.IsType(textEnded.Partial!.Content[textEnded.ContentIndex]).Text); + + var toolStarted = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallStarted); + var toolDeltas = events.Where(item => item.Kind == ModelStreamEventKind.ToolCallDelta).ToArray(); + Assert.NotEmpty(toolDeltas); + var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + var toolEvents = events.Where(item => item.Kind is + ModelStreamEventKind.ToolCallStarted or + ModelStreamEventKind.ToolCallDelta or + ModelStreamEventKind.ToolCallEnded); + Assert.All(toolEvents, item => + { + Assert.Equal(toolStarted.ContentIndex, item.ContentIndex); + var partialToolCall = Assert.IsType(item.Partial!.Content[item.ContentIndex]); + AssertJsonObject(partialToolCall.ArgumentsJson); + }); + + var terminalToolCall = Assert.IsType(response.Content[2]); + var endedToolCall = Assert.IsType(toolEnded.ToolCall); + var endedPartialToolCall = Assert.IsType(toolEnded.Partial!.Content[toolEnded.ContentIndex]); + Assert.Equal(terminalToolCall.Id, endedToolCall.Id); + Assert.Equal(terminalToolCall.Name, endedToolCall.Name); + Assert.Equal("{\"x\":1}", endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ArgumentsJson, endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ThoughtSignature, endedToolCall.ThoughtSignature); + Assert.Equal(terminalToolCall.Namespace, endedToolCall.Namespace); + Assert.Equal(endedToolCall.Id, toolEnded.ToolCallId); + Assert.Equal(endedToolCall.Name, toolEnded.ToolName); + AssertToolCallEqual(endedToolCall, endedPartialToolCall); + } + + [Fact] + public async Task SerializesCacheImagesStrictToolsAndBudgetThinking() + { + ConverseStreamRequest? captured = null; + var options = new BedrockConverseProviderOptions + { + SupportsStrictTools = true, + ToolChoice = BedrockToolChoice.Tool, + RequiredToolName = "inspect", + Transport = (request, token) => Capture(request, token), + }; + var provider = new BedrockConverseProvider(options); + var tool = new ToolDefinition( + "inspect", + "Inspect", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}}}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var call = new ToolCallContent("tool-1", "inspect", "{\"x\":1}"); + var request = new ModelRequest( + "anthropic.claude-sonnet-4-5", + "rules", + new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { new ReasoningContent("plan", "opaque"), call }, + DateTimeOffset.UnixEpoch, + model: "anthropic.claude-sonnet-4-5", + stopReason: ModelStopReason.ToolUse, + provider: "amazon-bedrock", + api: "bedrock-converse-stream"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] + { + new TextContent("clear"), + new BinaryContent(AgentMediaKind.Image, "dG9vbA==", "image/png"), + }), + DateTimeOffset.UnixEpoch), + }, + new[] { tool }, + new ModelParameters + { + ReasoningLevel = "medium", + CacheRetention = ModelCacheRetention.Long, + ReasoningBudgets = new Dictionary { ["medium"] = 9000 }, + }, + "session", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.NotNull(captured); + Assert.Equal(CacheTTL.ONE_HOUR, captured!.System[1].CachePoint.Ttl); + Assert.Equal(ImageFormat.Png, captured.Messages[0].Content[1].Image.Format); + Assert.Equal("opaque", captured.Messages[1].Content[0].ReasoningContent.ReasoningText.Signature); + Assert.Equal("tool-1", captured.Messages[2].Content[0].ToolResult.ToolUseId); + Assert.Equal(ImageFormat.Png, captured.Messages[2].Content[0].ToolResult.Content[1].Image.Format); + Assert.True(captured.ToolConfig.Tools[0].ToolSpec.Strict); + Assert.Equal("inspect", captured.ToolConfig.ToolChoice.Tool.Name); + var fields = captured.AdditionalModelRequestFields.AsDictionary(); + Assert.Equal(9000, fields["thinking"].AsDictionary()["budget_tokens"].AsInt()); + Assert.Equal("summarized", fields["thinking"].AsDictionary()["display"].AsString()); + Assert.Equal("interleaved-thinking-2025-05-14", fields["anthropic_beta"].AsList()[0].AsString()); + + async IAsyncEnumerable Capture( + ConverseStreamRequest value, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token) + { + captured = value; + await Task.Yield(); + token.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.MessageStop("end_turn"); + } + } + + [Fact] + public async Task UsesAdaptiveThinkingForNewClaudeModels() + { + ConverseStreamRequest? captured = null; + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (request, token) => Capture(request, token), + }); + var request = new ModelRequest( + "anthropic.claude-opus-4-6-v1:0", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters { ReasoningLevel = "high" }, + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + var fields = captured!.AdditionalModelRequestFields.AsDictionary(); + Assert.Equal("adaptive", fields["thinking"].AsDictionary()["type"].AsString()); + Assert.Equal("high", fields["output_config"].AsDictionary()["effort"].AsString()); + + async IAsyncEnumerable Capture( + ConverseStreamRequest value, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token) + { + captured = value; + await Task.Yield(); + token.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.MessageStop("end_turn"); + } + } + + [Fact] + public async Task PreservesUnknownStopAsFailedTerminal() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (_, token) => UnknownStop(token), + }); + + var events = await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken)); + + var terminal = events.Last(); + Assert.Equal(ModelStreamEventKind.Failed, terminal.Kind); + Assert.Equal("guardrail_intervened", terminal.Response!.RawStopReason); + Assert.Equal("Provider stopped with: guardrail_intervened", terminal.Response.ErrorMessage); + } + + [Fact] + public async Task RejectsMissingMessageStop() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (_, token) => MissingStop(token), + }); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken))); + Assert.Contains("message_stop", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConvertsMessagesWithoutInvalidEmptyBlocks() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (_, token) => MissingStop(token), + }); + var firstCall = new ToolCallContent("one", "inspect", "{}"); + var secondCall = new ToolCallContent("two", "inspect", "{}"); + var request = new ModelRequest( + "anthropic.claude-sonnet-4-5", + string.Empty, + new AgentMessage[] + { + new(AgentRole.User, new AgentContent[] { new TextContent("\ud83d") }, DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { new TextContent("\ud83d") }, + DateTimeOffset.UnixEpoch, + model: "anthropic.claude-sonnet-4-5", + stopReason: ModelStopReason.Stop, + provider: "amazon-bedrock", + api: "bedrock-converse-stream"), + AgentMessage.ToolResult(firstCall, new ToolResult(new AgentContent[] { new TextContent(" ") }), DateTimeOffset.UnixEpoch), + AgentMessage.ToolResult( + secondCall, + new ToolResult(new AgentContent[] { new TextContent("done") }), + DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters { CacheRetention = ModelCacheRetention.None }, + null, + "run", + 1); + + var payload = provider.BuildRequest(request); + + Assert.Equal( + "user:1|user:2", + string.Join("|", payload.Messages.Select(value => value.Role.Value + ":" + value.Content.Count))); + Assert.Equal("", payload.Messages[0].Content[0].Text); + Assert.Equal(2, payload.Messages[1].Content.Count); + Assert.Equal("", payload.Messages[1].Content[0].ToolResult.Content[0].Text); + Assert.Equal("done", payload.Messages[1].Content[1].ToolResult.Content[0].Text); + } + + [Fact] + public void ReplaysReasoningOnlyWhenWireFormatAcceptsIt() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + Transport = (_, token) => MissingStop(token), + }); + var unsignedClaude = AssistantRequest( + "anthropic.claude-sonnet-4-5", + new ReasoningContent("plan")); + var signedClaude = AssistantRequest( + "anthropic.claude-sonnet-4-5", + new ReasoningContent("plan", "opaque")); + var unsignedOther = AssistantRequest( + "amazon.nova-lite-v1:0", + new ReasoningContent("plan", "foreign")); + + Assert.Equal("plan", provider.BuildRequest(unsignedClaude).Messages[0].Content[0].Text); + Assert.Equal("opaque", provider.BuildRequest(signedClaude).Messages[0].Content[0].ReasoningContent.ReasoningText.Signature); + Assert.Null(provider.BuildRequest(unsignedOther).Messages[0].Content[0].ReasoningContent.ReasoningText.Signature); + } + + [Fact] + public void FiltersReservedHeadersBeforeSigning() + { + var headers = BedrockConverseProvider.NormalizeHeaders(new Dictionary + { + ["Authorization"] = "bad", + ["HOST"] = "bad", + ["x-amz-date"] = "bad", + ["x-game-session"] = "session", + }); + var outgoing = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["authorization"] = "signed", + ["host"] = "service", + }; + + AwsBedrockTransport.ApplyHeaders(outgoing, headers); + + Assert.Single(headers); + Assert.Equal(3, outgoing.Count); + Assert.Equal("signed", outgoing["authorization"]); + Assert.Equal("service", outgoing["host"]); + Assert.Equal("session", outgoing["x-game-session"]); + Assert.DoesNotContain(outgoing.Keys, key => key.StartsWith("x-amz-", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void TombstonesCannotDeleteSigningHeadersAndAreNotForwarded() + { + var headers = BedrockConverseProvider.NormalizeHeaders(new Dictionary + { + ["Authorization"] = null, + ["x-amz-security-token"] = null, + ["x-game-session"] = null, + ["x-feature"] = "enabled", + }); + + Assert.Single(headers); + Assert.Equal("enabled", headers["x-feature"]); + } + + [Fact] + public void CapturesBoundedAwsFailureMetadata() + { + var source = new AmazonBedrockRuntimeException( + "invalid model", + ErrorType.Sender, + "ValidationException", + "request-1", + HttpStatusCode.BadRequest); + + var failure = AwsBedrockTransport.CreateProviderFailure(source, null, null); + + var diagnostic = Assert.Single(failure.Diagnostics); + Assert.Equal("bedrock_response_failure", diagnostic.Code); + Assert.Equal( + "{\"status\":400,\"errorCode\":\"ValidationException\",\"requestId\":\"request-1\"}", + diagnostic.DataJson); + Assert.False(failure.IsTransient); + Assert.Equal(400, failure.StatusCode); + Assert.Same(source, failure.InnerException); + } + + [Fact] + public void AwsFailureKeepsDiagnosticsAndRetryMetadataTogether() + { + var source = new AmazonBedrockRuntimeException( + "temporarily unavailable", + ErrorType.Receiver, + "ServiceUnavailableException", + "request-2", + HttpStatusCode.ServiceUnavailable); + + var failure = AwsBedrockTransport.CreateProviderFailure(source, null, null); + + Assert.True(failure.IsTransient); + Assert.Equal(503, failure.StatusCode); + Assert.Equal("bedrock_response_failure", Assert.Single(failure.Diagnostics).Code); + } + + [Fact] + public void UnknownLocalFailureIsTerminalButKnownTransportFailureIsTransient() + { + var unknown = AwsBedrockTransport.CreateProviderFailure( + new InvalidOperationException("bad local configuration"), + null, + null); + var transport = AwsBedrockTransport.CreateProviderFailure( + new IOException("connection reset"), + null, + null); + + Assert.False(unknown.IsTransient); + Assert.True(transport.IsTransient); + } + + [Fact] + public async Task SharedClientHeaderScopesAreSerialized() + { + var sharedClient = new object(); + using var first = await BedrockClientRequestGate.EnterAsync( + sharedClient, + TestContext.Current.CancellationToken); + var second = BedrockClientRequestGate.EnterAsync( + sharedClient, + TestContext.Current.CancellationToken).AsTask(); + await Task.Delay(20, TestContext.Current.CancellationToken); + Assert.False(second.IsCompleted); + + first.Dispose(); + using var acquired = await second; + using var independent = await BedrockClientRequestGate.EnterAsync( + new object(), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task BedrockResponseObservationOnlyCarriesRequestIdentity() + { + ProviderResponseObservation? observed = null; + + var outcome = await AwsBedrockTransport.ObserveResponseAsync( + "amazon-bedrock", + "bedrock-converse-stream", + "model", + 200, + "request-3", + (value, _) => + { + observed = value; + return default; + }, + 500, + TestContext.Current.CancellationToken); + + Assert.Equal(ProviderResponseObserverOutcome.Completed, outcome); + Assert.NotNull(observed); + Assert.Equal("request-3", observed!.Metadata["request-id"]); + Assert.Single(observed.Metadata); + } + + [Fact] + public void OmitsUntrustedOversizedAwsMetadata() + { + var source = new AmazonBedrockRuntimeException( + "invalid model", + ErrorType.Sender, + new string('E', 300), + new string('R', 1100), + HttpStatusCode.Forbidden); + + var diagnostic = Assert.Single(AwsBedrockTransport.CreateProviderFailure(source, null, null).Diagnostics); + + Assert.Equal("{\"status\":403}", diagnostic.DataJson); + } + + [Fact] + public void ResolvesArnConfiguredEnvironmentAndEndpointRegionsInOrder() + { + Assert.Equal( + "us-gov-west-1", + BedrockConverseProvider.ResolveRegion( + "arn:aws-us-gov:bedrock:us-gov-west-1:123:application-inference-profile/test", + "eu-west-1", + "https://bedrock-runtime.eu-central-1.amazonaws.com", + "us-east-2", + "ap-south-1")); + Assert.Equal( + "eu-west-1", + BedrockConverseProvider.ResolveRegion("model", "eu-west-1", null, "us-east-2", null)); + Assert.Equal( + "us-east-2", + BedrockConverseProvider.ResolveRegion("model", null, null, "us-east-2", "ap-south-1")); + Assert.Equal( + "eu-central-1", + BedrockConverseProvider.ResolveRegion( + "model", + null, + "https://bedrock-runtime.eu-central-1.amazonaws.com", + null, + null)); + } + + [Fact] + public void UsesResolvedModelNameForInferenceProfileCapabilities() + { + var provider = new BedrockConverseProvider(new BedrockConverseProviderOptions + { + ModelDisplayNameResolver = _ => "Claude Opus 4.6", + Transport = (_, token) => MissingStop(token), + }); + var request = new ModelRequest( + "arn:aws:bedrock:us-east-1:123:application-inference-profile/custom", + "rules", + new[] + { + new AgentMessage(AgentRole.User, new[] { new TextContent("hello") }, DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters { ReasoningLevel = "high" }, + null, + "run", + 1); + + var payload = provider.BuildRequest(request); + var fields = payload.AdditionalModelRequestFields.AsDictionary(); + + Assert.Equal("adaptive", fields["thinking"].AsDictionary()["type"].AsString()); + Assert.Equal(CachePointType.Default, payload.System[1].CachePoint.Type); + Assert.Equal(CachePointType.Default, payload.Messages[0].Content[^1].CachePoint.Type); + } + + private static ModelRequest AssistantRequest(string model, AgentContent content) => + new( + model, + string.Empty, + new[] + { + new AgentMessage( + AgentRole.Assistant, + new[] { content }, + DateTimeOffset.UnixEpoch, + model: model, + stopReason: ModelStopReason.Stop, + provider: "amazon-bedrock", + api: "bedrock-converse-stream"), + }, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + private static async IAsyncEnumerable SuccessfulStream( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.ReasoningDelta(0, "plan", "signature"); + yield return BedrockProtocolEvent.ContentStop(0); + yield return BedrockProtocolEvent.TextDelta(1, "hello"); + yield return BedrockProtocolEvent.ContentStop(1); + yield return BedrockProtocolEvent.ContentStart(2, "tool-1", "move"); + yield return BedrockProtocolEvent.ToolDelta(2, "{\"x\":"); + yield return BedrockProtocolEvent.ToolDelta(2, "1}"); + yield return BedrockProtocolEvent.ContentStop(2); + yield return BedrockProtocolEvent.MessageStop("tool_use"); + yield return BedrockProtocolEvent.Usage(10, 2, 3, 4); + } + + private static async IAsyncEnumerable UnknownStop( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + yield return BedrockProtocolEvent.MessageStop("guardrail_intervened"); + } + + private static async IAsyncEnumerable MissingStop( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return BedrockProtocolEvent.MessageStart("assistant"); + } + + private static ModelRequest Request(string model) => + new(model, string.Empty, Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); + + private static async Task> CollectAsync(IAsyncEnumerable stream) + { + var result = new List(); + await foreach (var item in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + result.Add(item); + } + + return result; + } + + private static void AssertJsonObject(string value) + { + using var document = JsonDocument.Parse(value); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + } + + private static void AssertToolCallEqual(ToolCallContent expected, ToolCallContent actual) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.ArgumentsJson, actual.ArgumentsJson); + Assert.Equal(expected.ThoughtSignature, actual.ThoughtSignature); + Assert.Equal(expected.Namespace, actual.Namespace); + } +} diff --git a/tests/OpenGameAgent.Providers.Bedrock.Tests/OpenGameAgent.Providers.Bedrock.Tests.csproj b/tests/OpenGameAgent.Providers.Bedrock.Tests/OpenGameAgent.Providers.Bedrock.Tests.csproj new file mode 100644 index 0000000..2fc370b --- /dev/null +++ b/tests/OpenGameAgent.Providers.Bedrock.Tests/OpenGameAgent.Providers.Bedrock.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.Bedrock.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json new file mode 100644 index 0000000..fad414d --- /dev/null +++ b/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json @@ -0,0 +1,238 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "AWSSDK.BedrockRuntime": { + "type": "Transitive", + "resolved": "4.0.101", + "contentHash": "vBUUBQOwhEd75Zy5b5pDE+Yp5kTSb7WkE8pfpKa/ePk6WV748zqTQnObdFYBfrI3ASyXwCVV4LFDVbkgDBzOeA==", + "dependencies": { + "AWSSDK.Core": "[4.0.100.9, 5.0.0)" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "4.0.100.9", + "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.bedrock": { + "type": "Project", + "dependencies": { + "AWSSDK.BedrockRuntime": "[4.0.101, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.Google.Tests/GoogleGenerativeProviderTests.cs b/tests/OpenGameAgent.Providers.Google.Tests/GoogleGenerativeProviderTests.cs new file mode 100644 index 0000000..6f7c2a3 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Google.Tests/GoogleGenerativeProviderTests.cs @@ -0,0 +1,452 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.Google.Tests; + +public sealed class GoogleGenerativeProviderTests +{ + private const string ValidSignature = "AAAAAAAAAAAAAAAAAAAAAA=="; + + [Fact] + public async Task StreamsReasoningTextToolsSignaturesAndDetailedUsage() + { + const string stream = """ + data: {"responseId":"response-1","candidates":[{"content":{"parts":[{"thought":true,"text":"plan","thoughtSignature":"AAAAAAAAAAAAAAAAAAAAAA=="}]}}]} + + data: {"candidates":[{"content":{"parts":[{"thought":true,"text":" more"},{"text":"hello","thoughtSignature":"AAAAAAAAAAAAAAAAAAAAAA=="},{"functionCall":{"id":"call-1","name":"move","args":{"x":1}},"thoughtSignature":"AAAAAAAAAAAAAAAAAAAAAA=="}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":13,"cachedContentTokenCount":3,"candidatesTokenCount":4,"thoughtsTokenCount":2,"totalTokenCount":19}} + + """; + var provider = Create(new StubHandler(_ => Response(stream))); + + var events = await CollectAsync(provider.StreamAsync(Request("gemini-3-pro-preview"), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("STOP", response.RawStopReason); + var reasoning = Assert.IsType(response.Content[0]); + Assert.Equal("plan more", reasoning.Text); + Assert.Equal(ValidSignature, reasoning.Signature); + var text = Assert.IsType(response.Content[1]); + Assert.Equal("hello", text.Text); + Assert.Equal(ValidSignature, text.Signature); + var tool = Assert.IsType(response.Content[2]); + Assert.Equal("call-1", tool.Id); + Assert.Equal("{\"x\":1}", tool.ArgumentsJson); + Assert.Equal(ValidSignature, tool.ThoughtSignature); + Assert.Equal(10, response.Usage.InputTokens); + Assert.Equal(3, response.Usage.CacheReadTokens); + Assert.Equal(6, response.Usage.OutputTokens); + Assert.Equal(2, response.Usage.ReasoningTokens); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ReasoningStarted); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + + var reasoningEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded); + Assert.Equal(reasoning.Text, reasoningEnded.Content); + var endedReasoning = Assert.IsType(reasoningEnded.Partial!.Content[reasoningEnded.ContentIndex]); + Assert.Equal(reasoning.Text, endedReasoning.Text); + Assert.Equal(reasoning.Signature, endedReasoning.Signature); + + var textEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.TextEnded); + Assert.Equal(text.Text, textEnded.Content); + var endedText = Assert.IsType(textEnded.Partial!.Content[textEnded.ContentIndex]); + Assert.Equal(text.Text, endedText.Text); + Assert.Equal(text.Signature, endedText.Signature); + + var toolStarted = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallStarted); + var toolDeltas = events.Where(item => item.Kind == ModelStreamEventKind.ToolCallDelta).ToArray(); + Assert.NotEmpty(toolDeltas); + var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + var toolEvents = events.Where(item => item.Kind is + ModelStreamEventKind.ToolCallStarted or + ModelStreamEventKind.ToolCallDelta or + ModelStreamEventKind.ToolCallEnded); + Assert.All(toolEvents, item => + { + Assert.Equal(toolStarted.ContentIndex, item.ContentIndex); + var partialToolCall = Assert.IsType(item.Partial!.Content[item.ContentIndex]); + AssertJsonObject(partialToolCall.ArgumentsJson); + }); + + var endedToolCall = Assert.IsType(toolEnded.ToolCall); + var endedPartialToolCall = Assert.IsType(toolEnded.Partial!.Content[toolEnded.ContentIndex]); + Assert.Equal(tool.Id, endedToolCall.Id); + Assert.Equal(tool.Name, endedToolCall.Name); + Assert.Equal("{\"x\":1}", endedToolCall.ArgumentsJson); + Assert.Equal(tool.ArgumentsJson, endedToolCall.ArgumentsJson); + Assert.Equal(ValidSignature, endedToolCall.ThoughtSignature); + Assert.Equal(tool.ThoughtSignature, endedToolCall.ThoughtSignature); + Assert.Equal(tool.Namespace, endedToolCall.Namespace); + Assert.Equal(endedToolCall.Id, toolEnded.ToolCallId); + Assert.Equal(endedToolCall.Name, toolEnded.ToolName); + AssertToolCallEqual(endedToolCall, endedPartialToolCall); + } + + [Fact] + public async Task SerializesGemini3HistoryImagesStrictToolsAndThinking() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + options.ToolChoice = GoogleToolChoice.Auto; + var provider = new GoogleGenerativeProvider(options); + var tool = new ToolDefinition( + "inspect", + "Inspect", + "{\"$schema\":\"draft\",\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}},\"required\":[\"x\"]}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var call = new ToolCallContent("call-1", "inspect", "{\"x\":1}", ValidSignature); + var messages = new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent(string.Empty, ValidSignature), + new TextContent(string.Empty, ValidSignature), + call, + }, + DateTimeOffset.UnixEpoch, + model: "gemini-3-pro-preview", + stopReason: ModelStopReason.ToolUse, + provider: "google", + api: "google-generative-ai"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] + { + new TextContent("clear"), + new BinaryContent(AgentMediaKind.Image, "dG9vbA==", "image/png"), + }), + DateTimeOffset.UnixEpoch), + }; + var request = new ModelRequest( + "gemini-3-pro-preview", + "rules", + messages, + new[] { tool }, + new ModelParameters { ReasoningLevel = "high", Temperature = 0.2, MaxOutputTokens = 50 }, + "session", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal("test-key", handler.ApiKey); + Assert.Contains("gemini-3-pro-preview", handler.RequestUri, StringComparison.Ordinal); + Assert.Contains("alt=sse", handler.RequestUri, StringComparison.Ordinal); + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.Equal("rules", root.GetProperty("systemInstruction").GetProperty("parts")[0].GetProperty("text").GetString()); + Assert.Equal("HIGH", root.GetProperty("generationConfig").GetProperty("thinkingConfig").GetProperty("thinkingLevel").GetString()); + Assert.True(root.GetProperty("generationConfig").GetProperty("thinkingConfig").GetProperty("includeThoughts").GetBoolean()); + Assert.Equal("VALIDATED", root.GetProperty("toolConfig").GetProperty("functionCallingConfig").GetProperty("mode").GetString()); + var assistantParts = root.GetProperty("contents")[1].GetProperty("parts"); + Assert.Equal(ValidSignature, assistantParts[0].GetProperty("thoughtSignature").GetString()); + Assert.Equal(ValidSignature, assistantParts[1].GetProperty("thoughtSignature").GetString()); + Assert.Equal("call-1", assistantParts[2].GetProperty("functionCall").GetProperty("id").GetString()); + var functionResponse = root.GetProperty("contents")[2].GetProperty("parts")[0].GetProperty("functionResponse"); + Assert.Equal("call-1", functionResponse.GetProperty("id").GetString()); + Assert.Equal("dG9vbA==", functionResponse.GetProperty("parts")[0].GetProperty("inlineData").GetProperty("data").GetString()); + } + + [Fact] + public async Task Gemini2UsesSeparateToolImageTurnAndCanDisableThinking() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + options.UseLegacyOpenApiToolSchemas = true; + var provider = new GoogleGenerativeProvider(options); + var call = new ToolCallContent("call-1", "read", "{}"); + var request = new ModelRequest( + "gemini-2.5-flash", + string.Empty, + new AgentMessage[] + { + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "gemini-2.5-flash", + stopReason: ModelStopReason.ToolUse, + provider: "google", + api: "google-generative-ai"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] { new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png") }), + DateTimeOffset.UnixEpoch), + }, + new[] + { + new ToolDefinition( + "read", + "Read", + "{\"$schema\":\"draft\",\"type\":\"object\",\"properties\":{\"path\":{\"$id\":\"nested\",\"type\":\"string\"}}}"), + }, + new ModelParameters { ReasoningLevel = "off" }, + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.Equal(0, root.GetProperty("generationConfig").GetProperty("thinkingConfig").GetProperty("thinkingBudget").GetInt32()); + var assistantCall = root.GetProperty("contents")[0].GetProperty("parts")[0].GetProperty("functionCall"); + Assert.False(assistantCall.TryGetProperty("id", out _)); + Assert.Equal("Tool result image:", root.GetProperty("contents")[2].GetProperty("parts")[0].GetProperty("text").GetString()); + var parameters = root.GetProperty("tools")[0].GetProperty("functionDeclarations")[0].GetProperty("parameters"); + Assert.False(parameters.TryGetProperty("$schema", out _)); + Assert.False(parameters.GetProperty("properties").GetProperty("path").TryGetProperty("$id", out _)); + } + + [Fact] + public async Task CrossModelReplayDropsOpaqueSignaturesAndNormalizesToolIds() + { + var handler = new StubHandler(_ => Response(StopStream())); + var provider = Create(handler); + var call = new ToolCallContent("foreign|call/1", "move", "{}", ValidSignature); + var request = new ModelRequest( + "gemini-3-flash-preview", + string.Empty, + new AgentMessage[] + { + new( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("foreign plan", ValidSignature), + new TextContent("answer", ValidSignature), + call, + }, + DateTimeOffset.UnixEpoch, + model: "other-model", + stopReason: ModelStopReason.ToolUse, + provider: "other", + api: "other-api"), + AgentMessage.ToolResult(call, new ToolResult(new[] { new TextContent("done") }), DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var contents = document.RootElement.GetProperty("contents"); + var modelParts = contents[0].GetProperty("parts"); + Assert.Equal("foreign plan", modelParts[0].GetProperty("text").GetString()); + Assert.False(modelParts[0].TryGetProperty("thought", out _)); + Assert.False(modelParts[0].TryGetProperty("thoughtSignature", out _)); + Assert.False(modelParts[1].TryGetProperty("thoughtSignature", out _)); + Assert.Equal("foreign_call_1", modelParts[2].GetProperty("functionCall").GetProperty("id").GetString()); + Assert.Equal("foreign_call_1", contents[1].GetProperty("parts")[0].GetProperty("functionResponse").GetProperty("id").GetString()); + } + + [Fact] + public async Task PreservesProviderSafetyStopAsFailedTerminal() + { + var provider = Create(new StubHandler(_ => Response(""" + data: {"responseId":"response-1","candidates":[{"finishReason":"SAFETY"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":0,"totalTokenCount":1}} + + """))); + + var events = await CollectAsync(provider.StreamAsync(Request("gemini-2.5-flash"), TestContext.Current.CancellationToken)); + + var terminal = events.Last(); + Assert.Equal(ModelStreamEventKind.Failed, terminal.Kind); + Assert.Equal(ModelStopReason.Error, terminal.Response!.StopReason); + Assert.Equal("SAFETY", terminal.Response.RawStopReason); + Assert.Equal("Provider stopped with: SAFETY", terminal.Response.ErrorMessage); + } + + [Fact] + public async Task RejectsStreamWithoutFinishReason() + { + var provider = Create(new StubHandler(_ => Response(""" + data: {"responseId":"response-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]} + + """))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request("gemini-2.5-flash"), TestContext.Current.CancellationToken))); + Assert.Contains("finish reason", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task VertexUsesBearerCredential() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = new GoogleGenerativeProviderOptions( + new HttpClient(handler), + GoogleVertexCredentials.Endpoint("project", "us-central1"), + GoogleApiFlavor.Vertex) + { + Credential = "access-token", + }; + var provider = new GoogleGenerativeProvider(options); + + await CollectAsync(provider.StreamAsync(Request("gemini-3-flash-preview"), TestContext.Current.CancellationToken)); + + Assert.Equal("Bearer", handler.Authorization?.Scheme); + Assert.Equal("access-token", handler.Authorization?.Parameter); + Assert.Contains("models/gemini-3-flash-preview:streamGenerateContent", handler.RequestUri, StringComparison.Ordinal); + } + + [Fact] + public void BuildsRegionalVertexEndpointWithoutLosingModelPlaceholder() + { + var endpoint = GoogleVertexCredentials.Endpoint("my project", "us-central1"); + + Assert.Equal( + "https://us-central1-aiplatform.googleapis.com/v1/projects/my%20project/locations/us-central1/publishers/google/models/%7Bmodel%7D:streamGenerateContent", + endpoint.AbsoluteUri); + } + + [Fact] + public async Task ObservesSanitizedSuccessfulResponseMetadata() + { + ProviderResponseObservation? observed = null; + var response = Response(StopStream()); + response.Headers.TryAddWithoutValidation("x-goog-request-id", "google-request-1"); + response.Headers.TryAddWithoutValidation("authorization", "Bearer secret"); + var options = Options(new HttpClient(new StubHandler(_ => response))); + options.ResponseObserver = (value, _) => + { + observed = value; + return default; + }; + var provider = new GoogleGenerativeProvider(options); + + await CollectAsync(provider.StreamAsync(Request("gemini-3-flash-preview"), TestContext.Current.CancellationToken)); + + Assert.NotNull(observed); + Assert.Equal(200, observed!.StatusCode); + Assert.Equal("google-request-1", observed.Metadata["x-goog-request-id"]); + Assert.DoesNotContain(observed.Metadata.Values, value => value.Contains("secret", StringComparison.Ordinal)); + } + + [Fact] + public async Task TombstoneSuppressesOptionalSessionHeaderButCannotDeleteCredential() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + options.Headers["x-goog-request-params"] = null; + options.Headers["x-goog-api-key"] = null; + var request = new ModelRequest( + "gemini-3-flash-preview", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters(), + "session", + "run", + 1); + + await CollectAsync(new GoogleGenerativeProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken)); + + Assert.Null(handler.RequestParameters); + Assert.Equal("test-key", handler.ApiKey); + } + + private static GoogleGenerativeProvider Create(HttpMessageHandler handler) => + new(Options(new HttpClient(handler))); + + private static GoogleGenerativeProviderOptions Options(HttpClient client) => + new(client, new Uri("https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent")) + { + Credential = "test-key", + }; + + private static ModelRequest Request(string model) => + new(model, string.Empty, Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); + + private static string StopStream() => """ + data: {"responseId":"response-1","candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}} + + """; + + private static HttpResponseMessage Response(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }; + + private static async Task> CollectAsync(IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var item in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + events.Add(item); + } + + return events; + } + + private static void AssertJsonObject(string value) + { + using var document = JsonDocument.Parse(value); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + } + + private static void AssertToolCallEqual(ToolCallContent expected, ToolCallContent actual) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.ArgumentsJson, actual.ArgumentsJson); + Assert.Equal(expected.ThoughtSignature, actual.ThoughtSignature); + Assert.Equal(expected.Namespace, actual.Namespace); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _response; + + public StubHandler(Func response) + { + _response = response; + } + + public string? RequestBody { get; private set; } + + public string RequestUri { get; private set; } = string.Empty; + + public string? ApiKey { get; private set; } + + public AuthenticationHeaderValue? Authorization { get; private set; } + + public string? RequestParameters { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + RequestUri = request.RequestUri?.AbsoluteUri ?? string.Empty; + ApiKey = request.Headers.TryGetValues("x-goog-api-key", out var values) ? values.Single() : null; + Authorization = request.Headers.Authorization; + RequestParameters = request.Headers.TryGetValues("x-goog-request-params", out var parameters) + ? parameters.Single() + : null; + return _response(request); + } + } +} diff --git a/tests/OpenGameAgent.Providers.Google.Tests/OpenGameAgent.Providers.Google.Tests.csproj b/tests/OpenGameAgent.Providers.Google.Tests/OpenGameAgent.Providers.Google.Tests.csproj new file mode 100644 index 0000000..f143e49 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Google.Tests/OpenGameAgent.Providers.Google.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.Google.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json new file mode 100644 index 0000000..4c93823 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json @@ -0,0 +1,269 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Google.Apis": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "ZqODi2IvyTBezeGztemXv6U/+VinyqxxPiyoW2CZbzIrUp+a35Rt5tzUjXHPXK9nA1YQi/w8ABpYQpBm31ditw==", + "dependencies": { + "Google.Apis.Core": "1.75.0" + } + }, + "Google.Apis.Auth": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "hzuGwUBIQYdFkChXm62E5Suxe+q5PHt2uE5EunGBco2j01uQJGlUgzNujZvGHMlAIEHaytzhdn3v3v52ZPgv2Q==", + "dependencies": { + "Google.Apis": "1.75.0", + "Google.Apis.Core": "1.75.0", + "System.Management": "7.0.2" + } + }, + "Google.Apis.Core": { + "type": "Transitive", + "resolved": "1.75.0", + "contentHash": "7AuI44XP4LzMFiOjdk4GCtCxJTIWZcjrXLeGjLYYSpTHHbiPkvm76XNym7zPOnD90sIg+zdTulg+I6D5W5spTQ==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "GLltyqEsE5/3IE+zYRP5sNa1l44qKl9v+bfdMcwg+M9qnQf47wK3H0SUR/T+3N4JEQXF3vV4CSuuo0rsg+nq2A==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "/qEUN91mP/MUQmJnM5y5BdT7ZoPuVrtxnFlbJ8a3kBJGhe2wCzBfnPFtK2wTtEEcf3DMGR9J00GZZfg6HRI6yA==", + "dependencies": { + "System.CodeDom": "7.0.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.google": { + "type": "Project", + "dependencies": { + "Google.Apis.Auth": "[1.75.0, )", + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs b/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs new file mode 100644 index 0000000..2b3621c --- /dev/null +++ b/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs @@ -0,0 +1,1203 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.MessageGateway.Tests; + +public sealed class MessageGatewayProviderTests +{ + [Fact] + public async Task ProjectsContextOptionsAndDecodesTheCompleteEventProtocol() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"thinking_start\",\"contentIndex\":0}", + "{\"type\":\"thinking_delta\",\"contentIndex\":0,\"delta\":\"plan\"}", + "{\"type\":\"thinking_end\",\"contentIndex\":0,\"content\":\"plan\",\"contentSignature\":\"reason-signature\"}", + "{\"type\":\"text_start\",\"contentIndex\":1}", + "{\"type\":\"text_delta\",\"contentIndex\":1,\"delta\":\"hello\"}", + "{\"type\":\"text_end\",\"contentIndex\":1,\"content\":\"hello\",\"contentSignature\":\"text-signature\"}", + "{\"type\":\"toolcall_start\",\"contentIndex\":2,\"id\":\"call-1\",\"toolName\":\"move\"}", + "{\"type\":\"toolcall_delta\",\"contentIndex\":2,\"delta\":\"{\\\"x\\\":1}\"}", + "{\"type\":\"toolcall_end\",\"contentIndex\":2,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"call-1\",\"name\":\"move\",\"arguments\":{\"x\":1},\"thoughtSignature\":\"thought\",\"namespace\":\"world\"}}", + Done("toolUse", responseId: "response-1", rewrite: true)))); + var options = Options(handler); + options.AccessToken = "access-token"; + options.Headers["X-Game-Id"] = "game-1"; + options.Debug = true; + options.ToolChoice = MessageGatewayToolChoiceMode.Function; + options.ToolName = "move"; + var provider = new MessageGatewayProvider(options); + var parameters = new ModelParameters + { + Temperature = 0.4, + MaxOutputTokens = 321, + ReasoningLevel = "high", + CacheRetention = ModelCacheRetention.Long, + Transport = ModelTransport.ServerSentEvents, + }; + var request = new ModelRequest( + "world-model", + "You simulate a world.", + new[] { AgentMessage.User("advance", DateTimeOffset.UnixEpoch) }, + new[] + { + new ToolDefinition( + "move", + "Move an actor.", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"integer\"}}}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)), + }, + parameters, + "session-1", + "run-1", + 1); + + var events = await CollectAsync(provider, request, TestContext.Current.CancellationToken); + + Assert.Equal( + new[] + { + ModelStreamEventKind.Started, + ModelStreamEventKind.ReasoningStarted, + ModelStreamEventKind.ReasoningDelta, + ModelStreamEventKind.ReasoningEnded, + ModelStreamEventKind.TextStarted, + ModelStreamEventKind.TextDelta, + ModelStreamEventKind.TextEnded, + ModelStreamEventKind.ToolCallStarted, + ModelStreamEventKind.ToolCallDelta, + ModelStreamEventKind.ToolCallEnded, + ModelStreamEventKind.Completed, + }, + events.Select(item => item.Kind)); + var terminal = Assert.Single(events, item => item.IsTerminal).Response!; + Assert.Equal(ModelStopReason.ToolUse, terminal.StopReason); + Assert.Equal("response-1", terminal.ResponseId); + Assert.Equal("message-gateway", terminal.Api); + Assert.Equal("world-model", terminal.ResponseModel); + Assert.Equal(10, terminal.Usage.TotalTokens); + Assert.Equal("plan", Assert.IsType(terminal.Content[0]).Text); + Assert.Equal("reason-signature", Assert.IsType(terminal.Content[0]).Signature); + Assert.Equal("text-signature", Assert.IsType(terminal.Content[1]).Signature); + var call = Assert.IsType(terminal.Content[2]); + Assert.Equal("call-1", call.Id); + Assert.Equal("{\"x\":1}", call.ArgumentsJson); + Assert.Equal("thought", call.ThoughtSignature); + Assert.Equal("world", call.Namespace); + Assert.Equal("message_gateway_rewrite", Assert.Single(terminal.Diagnostics).Code); + + Assert.Equal(HttpMethod.Post, handler.Method); + Assert.Equal("https://gateway.example/v1/messages?debug=1", handler.Uri!.AbsoluteUri); + Assert.Equal("Bearer access-token", handler.Authorization); + Assert.Equal("game-1", handler.Headers["X-Game-Id"]); + Assert.Equal("text/event-stream", handler.Accept); + Assert.Equal("application/json", handler.ContentType); + using var body = JsonDocument.Parse(handler.Body!); + var root = body.RootElement; + Assert.Equal("world-model", root.GetProperty("model").GetString()); + var context = root.GetProperty("context"); + Assert.Equal("You simulate a world.", context.GetProperty("systemPrompt").GetString()); + Assert.Equal("advance", context.GetProperty("messages")[0].GetProperty("content").GetString()); + Assert.Equal(0, context.GetProperty("messages")[0].GetProperty("timestamp").GetInt64()); + var tool = context.GetProperty("tools")[0]; + Assert.Equal("move", tool.GetProperty("name").GetString()); + Assert.Equal("json_schema", tool.GetProperty("constrainedSampling").GetProperty("type").GetString()); + Assert.Equal("require", tool.GetProperty("constrainedSampling").GetProperty("strict").GetString()); + var projectedOptions = root.GetProperty("options"); + Assert.Equal(0.4, projectedOptions.GetProperty("temperature").GetDouble()); + Assert.Equal(321, projectedOptions.GetProperty("maxTokens").GetInt32()); + Assert.Equal("high", projectedOptions.GetProperty("reasoning").GetString()); + Assert.Equal("long", projectedOptions.GetProperty("cacheRetention").GetString()); + Assert.Equal("session-1", projectedOptions.GetProperty("sessionId").GetString()); + Assert.Equal( + "move", + projectedOptions.GetProperty("toolChoice").GetProperty("function").GetProperty("name").GetString()); + } + + [Fact] + public async Task ProjectsRicherContentWithStableLossyPlaceholders() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var provider = Provider(handler); + var user = new AgentMessage( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + new BinaryContent(AgentMediaKind.Audio, "YXVkaW8=", "audio/wav"), + new BinaryContent(AgentMediaKind.Video, "dmlkZW8=", "video/mp4"), + new BinaryContent(AgentMediaKind.File, "ZmlsZQ==", "application/octet-stream"), + new ResourceContent("game://asset/tree", "application/json", "tree"), + new JsonContent("{\"state\":1}"), + }, + DateTimeOffset.UnixEpoch); + + var events = await CollectAsync( + provider, + Request(messages: new[] { user }), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStopReason.Stop, Assert.Single(events).Response!.StopReason); + using var body = JsonDocument.Parse(handler.Body!); + var content = body.RootElement.GetProperty("context").GetProperty("messages")[0].GetProperty("content"); + Assert.Equal("text", content[0].GetProperty("type").GetString()); + Assert.Equal("image", content[1].GetProperty("type").GetString()); + Assert.Equal("aW1hZ2U=", content[1].GetProperty("data").GetString()); + Assert.Equal("[audio omitted: message gateway supports only text and images]", content[2].GetProperty("text").GetString()); + Assert.Equal("[video omitted: message gateway supports only text and images]", content[3].GetProperty("text").GetString()); + Assert.Equal("[file omitted: message gateway supports only text and images]", content[4].GetProperty("text").GetString()); + Assert.Equal("[resource omitted: inline data required]", content[5].GetProperty("text").GetString()); + Assert.Equal("{\"state\":1}", content[6].GetProperty("text").GetString()); + } + + [Fact] + public async Task NormalizesForeignTranscriptStateAndRepairsMissingToolResults() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var provider = Provider(handler); + var assistant = new AgentMessage( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("visible reasoning", "foreign-reason-signature"), + new ReasoningContent("secret reasoning", "foreign-redacted", redacted: true), + new TextContent("answer", "foreign-text-signature"), + new ToolCallContent("call-foreign", "inspect", "{}", "foreign-thought", "foreign-namespace"), + }, + DateTimeOffset.UnixEpoch, + model: "other-model", + stopReason: ModelStopReason.ToolUse, + usage: new ModelUsage(), + provider: "other-provider", + api: "other-api"); + var messages = new[] + { + assistant, + AgentMessage.User("continue", DateTimeOffset.UnixEpoch.AddSeconds(1)), + }; + + await CollectAsync(provider, Request(messages: messages), TestContext.Current.CancellationToken); + + Assert.DoesNotContain("secret reasoning", handler.Body!, StringComparison.Ordinal); + Assert.DoesNotContain("foreign-reason-signature", handler.Body!, StringComparison.Ordinal); + Assert.DoesNotContain("foreign-text-signature", handler.Body!, StringComparison.Ordinal); + Assert.DoesNotContain("foreign-thought", handler.Body!, StringComparison.Ordinal); + Assert.DoesNotContain("foreign-namespace", handler.Body!, StringComparison.Ordinal); + using var body = JsonDocument.Parse(handler.Body!); + var projected = body.RootElement.GetProperty("context").GetProperty("messages"); + Assert.Equal(3, projected.GetArrayLength()); + var projectedAssistant = projected[0]; + Assert.Equal("text", projectedAssistant.GetProperty("content")[0].GetProperty("type").GetString()); + Assert.Equal("visible reasoning", projectedAssistant.GetProperty("content")[0].GetProperty("text").GetString()); + Assert.Equal("answer", projectedAssistant.GetProperty("content")[1].GetProperty("text").GetString()); + Assert.False(projectedAssistant.GetProperty("content")[1].TryGetProperty("textSignature", out _)); + var projectedCall = projectedAssistant.GetProperty("content")[2]; + Assert.False(projectedCall.TryGetProperty("thoughtSignature", out _)); + Assert.False(projectedCall.TryGetProperty("namespace", out _)); + Assert.Equal("toolResult", projected[1].GetProperty("role").GetString()); + Assert.Equal("call-foreign", projected[1].GetProperty("toolCallId").GetString()); + Assert.Equal("No result provided", projected[1].GetProperty("content")[0].GetProperty("text").GetString()); + Assert.True(projected[1].GetProperty("isError").GetBoolean()); + Assert.Equal("continue", projected[2].GetProperty("content").GetString()); + } + + [Fact] + public async Task UsesExplicitAuthorizationAndReportsOnlySanitizedResponseMetadata() + { + ProviderResponseObservation? observed = null; + var handler = new CaptureHandler((_, _) => + { + var response = SseResponse(Done("stop")); + response.Headers.TryAddWithoutValidation("X-Request-Id", "request-7"); + response.Headers.TryAddWithoutValidation("Set-Cookie", "private=value"); + response.Headers.TryAddWithoutValidation("Authorization", "Bearer response-secret"); + return Task.FromResult(response); + }); + var options = Options(handler); + options.Headers["Authorization"] = "Bearer configured-token"; + options.ResponseObserver = (observation, _) => + { + observed = observation; + return ValueTask.CompletedTask; + }; + var provider = new MessageGatewayProvider(options); + var parameters = new ModelParameters + { + Extensions = new Dictionary + { + [MessageGatewayParameterKeys.Debug] = "true", + }, + }; + + await CollectAsync(provider, Request(parameters: parameters), TestContext.Current.CancellationToken); + + Assert.Equal("Bearer configured-token", handler.Authorization); + Assert.Equal("https://gateway.example/v1/messages?debug=1", handler.Uri!.AbsoluteUri); + Assert.NotNull(observed); + Assert.Equal(200, observed!.StatusCode); + Assert.Equal("request-7", observed.Metadata["x-request-id"]); + Assert.DoesNotContain("set-cookie", observed.Metadata.Keys, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("authorization", observed.Metadata.Keys, StringComparer.OrdinalIgnoreCase); + Assert.DoesNotContain("response-secret", JsonSerializer.Serialize(observed.Metadata), StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsServerErrorAndRewriteAsInBandTerminalEvents() + { + var errorHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"error\",\"reason\":\"error\",\"usage\":" + Usage() + ",\"errorMessage\":\"route failed\",\"responseId\":\"response-error\"}"))); + var errorEvents = await CollectAsync( + Provider(errorHandler), + Request(), + TestContext.Current.CancellationToken); + + var failure = Assert.Single(errorEvents); + Assert.Equal(ModelStreamEventKind.Failed, failure.Kind); + Assert.Equal(ModelStopReason.Error, failure.Response!.StopReason); + Assert.Equal("route failed", failure.Response.ErrorMessage); + Assert.Equal("response-error", failure.Response.ResponseId); + + var abortedHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"error\",\"reason\":\"aborted\",\"usage\":" + Usage() + "}"))); + var abortedEvents = await CollectAsync( + Provider(abortedHandler), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStopReason.Aborted, Assert.Single(abortedEvents).Response!.StopReason); + } + + [Fact] + public async Task PreservesTypedHttpFailuresWithBoundedDiagnostics() + { + var handler = new CaptureHandler((_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + ReasonPhrase = "Rate Limited", + Content = new StringContent( + "{\"error\":{\"message\":\"try later\",\"code\":\"rate_limit\",\"details\":\"private\"}}", + Encoding.UTF8, + "application/json"), + }; + response.Headers.TryAddWithoutValidation("X-Request-Id", "request-http-error"); + response.Headers.TryAddWithoutValidation("Set-Cookie", "private=value"); + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(3)); + return Task.FromResult(response); + }); + + var failure = await Assert.ThrowsAsync(() => + CollectAsync(Provider(handler), Request(), TestContext.Current.CancellationToken)); + + Assert.Contains("HTTP 429", failure.Message, StringComparison.Ordinal); + Assert.Contains("try later", failure.Message, StringComparison.Ordinal); + Assert.Contains("rate_limit", failure.Message, StringComparison.Ordinal); + Assert.True(failure.IsTransient); + Assert.Equal(TimeSpan.FromSeconds(3), failure.RetryAfter); + var diagnostic = Assert.Single(failure.Diagnostics); + Assert.Equal("message_gateway_response_failure", diagnostic.Code); + Assert.DoesNotContain("private=value", diagnostic.DataJson!, StringComparison.Ordinal); + Assert.Contains("request-http-error", diagnostic.DataJson!, StringComparison.Ordinal); + } + + [Fact] + public async Task RedactsDynamicCredentialsFromHttpErrorsAndObservedMetadata() + { + const string secret = "dynamic-test-secret"; + ProviderResponseObservation? observed = null; + var handler = new CaptureHandler((_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + "{\"error\":{\"message\":\"Bearer " + secret + "\",\"code\":\"" + secret + "\",\"details\":\"" + secret + "\"}}", + Encoding.UTF8, + "application/json"), + }; + response.Headers.TryAddWithoutValidation("X-Request-Id", secret); + return Task.FromResult(response); + }); + var options = Options(handler); + options.GetAccessTokenAsync = _ => new ValueTask(secret); + options.ResponseObserver = (observation, _) => + { + observed = observation; + return ValueTask.CompletedTask; + }; + + var failure = await Assert.ThrowsAsync(() => + CollectAsync( + new MessageGatewayProvider(options), + Request(), + TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(secret, failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(secret, JsonSerializer.Serialize(failure.Diagnostics), StringComparison.Ordinal); + Assert.NotNull(observed); + Assert.DoesNotContain(secret, JsonSerializer.Serialize(observed!.Metadata), StringComparison.Ordinal); + Assert.Contains("redacted", failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DoesNotExposeStructuredErrorDetailsWhenSafeFieldsAreInvalid() + { + const string privateDetail = "private-upstream-detail"; + var handler = new CaptureHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + "{\"error\":{\"message\":7,\"code\":9,\"details\":\"" + privateDetail + "\"}}", + Encoding.UTF8, + "application/json"), + })); + + var failure = await Assert.ThrowsAsync(() => + CollectAsync(Provider(handler), Request(), TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(privateDetail, failure.Message, StringComparison.Ordinal); + Assert.Contains("HTTP 400", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SanitizesCredentialEchoesInMeaningfulStreamErrors() + { + const string secret = "stream-test-secret"; + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"error\",\"reason\":\"error\",\"usage\":" + Usage() + + ",\"errorMessage\":\"Bearer " + secret + "\\r\\nfailed\",\"responseId\":\"" + secret + "\"}"))); + var options = Options(handler); + options.AccessToken = secret; + + var events = await CollectAsync( + new MessageGatewayProvider(options), + Request(), + TestContext.Current.CancellationToken); + + var terminal = Assert.Single(events).Response!; + Assert.Equal(ModelStopReason.Error, terminal.StopReason); + Assert.DoesNotContain(secret, terminal.ErrorMessage!, StringComparison.Ordinal); + Assert.DoesNotContain('\r', terminal.ErrorMessage!); + Assert.DoesNotContain('\n', terminal.ErrorMessage!); + Assert.Equal("[redacted]", terminal.ResponseId); + } + + [Fact] + public async Task RetryWrapperRetriesTypedRateLimitBeforeAnyMeaningfulOutput() + { + var attempt = 0; + var handler = new CaptureHandler((_, _) => + { + attempt++; + if (attempt == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent( + "{\"error\":{\"message\":\"try again\",\"code\":\"rate_limit\"}}", + Encoding.UTF8, + "application/json"), + }; + response.Headers.RetryAfter = + new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero); + return Task.FromResult(response); + } + + return Task.FromResult(SseResponse(Done("stop"))); + }); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 2, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task RetryWrapperRetriesTransportFailureBeforeConnection() + { + var attempt = 0; + var handler = new CaptureHandler((_, _) => + { + attempt++; + return attempt == 1 + ? Task.FromException(new HttpRequestException("connection unavailable")) + : Task.FromResult(SseResponse(Done("stop"))); + }); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 2, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task RetryWrapperRetriesEmptyStreamErrorBeforeAnyMeaningfulOutput() + { + var attempt = 0; + var handler = new CaptureHandler((_, _) => + { + attempt++; + return Task.FromResult(attempt == 1 + ? SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"error\",\"reason\":\"error\",\"usage\":" + EmptyUsage() + + ",\"errorMessage\":\"route unavailable\"}") + : SseResponse(Done("stop"))); + }); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 2, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task RetryWrapperNeverReplaysAfterMeaningfulPartialOutput() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"text_start\",\"contentIndex\":0}", + "{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"visible\"}", + "{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"visible\"}", + "{\"type\":\"error\",\"reason\":\"error\",\"usage\":" + EmptyUsage() + + ",\"errorMessage\":\"failed after output\"}"))); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 3, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, events[^1].Kind); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.TextDelta); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task RetryWrapperNeverReplaysAfterReportedUsage() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"error\",\"reason\":\"error\",\"usage\":" + Usage() + + ",\"errorMessage\":\"failed after billing\"}"))); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 3, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, events[^1].Kind); + Assert.Equal(10, events[^1].Response!.Usage.TotalTokens); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task RetryWrapperPreservesHttpStatusWhenErrorBodyCannotBeDecoded() + { + var attempt = 0; + var handler = new CaptureHandler((_, _) => + { + attempt++; + if (attempt == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new ByteArrayContent(new byte[] { 0xff }), + }; + response.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + response.Headers.RetryAfter = + new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero); + return Task.FromResult(response); + } + + return Task.FromResult(SseResponse(Done("stop"))); + }); + var retrying = new RetryingModelProvider( + Provider(handler), + maximumAttempts: 2, + delay: _ => TimeSpan.Zero); + + var events = await CollectAsync(retrying, Request(), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.Equal(2, handler.RequestCount); + } + + [Fact] + public async Task MissingCredentialAndOversizedRequestFailBeforeNetworkIo() + { + var missingHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var missingProvider = new MessageGatewayProvider(Options(missingHandler)); + + var missingEvents = await CollectAsync( + missingProvider, + Request(), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(missingEvents).Kind); + Assert.Equal(0, missingHandler.RequestCount); + + var largeHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var largeOptions = Options(largeHandler); + largeOptions.AccessToken = "token"; + largeOptions.MaxRequestBytes = 128; + var largeEvents = await CollectAsync( + new MessageGatewayProvider(largeOptions), + Request(messages: new[] { AgentMessage.User(new string('x', 1_000), DateTimeOffset.UnixEpoch) }), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(largeEvents).Kind); + Assert.Equal(0, largeHandler.RequestCount); + } + + [Fact] + public async Task StrictlyRejectsMalformedJsonUnfinishedStreamsAndMismatchedToolPartials() + { + var malformedHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"text_start\",\"type\":\"done\",\"contentIndex\":0}"))); + var malformedEvents = await CollectAsync( + Provider(malformedHandler), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Started, malformedEvents[0].Kind); + Assert.Equal(ModelStreamEventKind.Failed, malformedEvents[^1].Kind); + Assert.Single(malformedEvents, item => item.IsTerminal); + + var unfinishedHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}"))); + var unfinishedEvents = await CollectAsync( + Provider(unfinishedHandler), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Started, unfinishedEvents[0].Kind); + Assert.Equal(ModelStreamEventKind.Failed, unfinishedEvents[^1].Kind); + Assert.Contains("without a terminal event", unfinishedEvents[^1].Response!.ErrorMessage, StringComparison.Ordinal); + + var mismatchHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"call-1\",\"toolName\":\"move\"}", + "{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"x\\\":1}\"}", + "{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"call-1\",\"name\":\"move\",\"arguments\":{\"x\":2}}}"))); + var mismatchEvents = await CollectAsync( + Provider(mismatchHandler), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Failed, mismatchEvents[^1].Kind); + Assert.Single(mismatchEvents, item => item.IsTerminal); + + var invalidCost = Done("stop").Replace("\"total\":1.0", "\"total\":99.0", StringComparison.Ordinal); + var costHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(invalidCost))); + var costEvents = await CollectAsync( + Provider(costHandler), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(costEvents).Kind); + Assert.Contains("cost totals", costEvents[0].Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task EnforcesSseEventAndResponseBounds() + { + var eventHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"" + new string('x', 256) + "\"}"))); + var eventOptions = Options(eventHandler); + eventOptions.AccessToken = "token"; + eventOptions.MaxEventBytes = 96; + var eventResults = await CollectAsync( + new MessageGatewayProvider(eventOptions), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Failed, eventResults[^1].Kind); + Assert.Single(eventResults, item => item.IsTerminal); + + var responseHandler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"text_start\",\"contentIndex\":0}", + "{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"" + new string('y', 2_000) + "\"}"))); + var responseOptions = Options(responseHandler); + responseOptions.AccessToken = "token"; + responseOptions.MaxResponseBytes = 512; + responseOptions.MaxEventBytes = 512; + var responseResults = await CollectAsync( + new MessageGatewayProvider(responseOptions), + Request(), + TestContext.Current.CancellationToken); + Assert.Equal(ModelStreamEventKind.Failed, responseResults[^1].Kind); + Assert.Single(responseResults, item => item.IsTerminal); + } + + [Fact] + public async Task BoundsCumulativePartialSnapshotMaterialization() + { + var frames = new List + { + "{\"type\":\"start\"}", + "{\"type\":\"text_start\",\"contentIndex\":0}", + }; + frames.AddRange(Enumerable.Range(0, 20).Select(_ => + "{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"abcdefghij\"}")); + frames.Add("{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"" + + new string('a', 200) + "\"}"); + frames.Add(Done("stop")); + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(frames.ToArray()))); + var options = Options(handler); + options.AccessToken = "token"; + options.MaxPartialSnapshotWork = 100; + + var events = await CollectAsync( + new MessageGatewayProvider(options), + Request(), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, events[^1].Kind); + Assert.Contains("partial-snapshot", events[^1].Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Single(events, item => item.IsTerminal); + } + + [Fact] + public async Task AcceptsStructurallyEquivalentReorderedToolArguments() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"call-1\",\"toolName\":\"move\"}", + "{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"x\\\":1,\\\"nested\\\":{\\\"a\\\":true,\\\"b\\\":2}}\"}", + "{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"call-1\",\"name\":\"move\",\"arguments\":{\"nested\":{\"b\":2.0,\"a\":true},\"x\":1.0}}}", + Done("toolUse")))); + + var events = await CollectAsync( + Provider(handler), + Request(), + TestContext.Current.CancellationToken); + + var terminal = Assert.Single(events, item => item.IsTerminal); + Assert.Equal(ModelStreamEventKind.Completed, terminal.Kind); + var call = Assert.IsType(Assert.Single(terminal.Response!.Content)); + using var arguments = JsonDocument.Parse(call.ArgumentsJson); + Assert.Equal(1, arguments.RootElement.GetProperty("x").GetDouble()); + Assert.True(arguments.RootElement.GetProperty("nested").GetProperty("a").GetBoolean()); + } + + [Fact] + public async Task RejectsFiniteCostPartsWhoseSumOverflows() + { + var usage = "{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0," + + "\"cost\":{\"input\":1e308,\"output\":1e308,\"cacheRead\":1e308,\"cacheWrite\":1e308,\"total\":1e308}}"; + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"done\",\"reason\":\"stop\",\"usage\":" + usage + "}"))); + + var events = await CollectAsync( + Provider(handler), + Request(), + TestContext.Current.CancellationToken); + + var terminal = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, terminal.Kind); + Assert.Contains("cost total", terminal.Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task SupportsMultilineSseCommentsAndDoneMarkers() + { + var raw = ": keepalive\n\n" + + "data: [DONE]\n\n" + + "event: message\n" + + "id: 1\n" + + "data: {\"type\":\"done\",\n" + + "data: \"reason\":\"stop\",\"usage\":" + Usage() + "}\n\n"; + var handler = new CaptureHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(raw, Encoding.UTF8, "text/event-stream"), + })); + + var events = await CollectAsync( + Provider(handler), + Request(), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + } + + [Fact] + public async Task RejectsControlCharactersInProtocolIdentifiers() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse( + "{\"type\":\"start\"}", + "{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"call\\r1\",\"toolName\":\"move\"}"))); + + var events = await CollectAsync( + Provider(handler), + Request(), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, events[^1].Kind); + Assert.Contains("field 'id'", events[^1].Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Single(events, item => item.IsTerminal); + } + + [Fact] + public async Task CallerCancellationInterruptsANonCooperativeCredentialCallback() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tokenResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var options = Options(handler); + options.GetAccessTokenAsync = _ => + { + entered.TrySetResult(true); + return new ValueTask(tokenResult.Task); + }; + var provider = new MessageGatewayProvider(options); + using var cancellation = new CancellationTokenSource(); + var operation = CollectAsync(provider, Request(), cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await operation.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)); + tokenResult.TrySetResult("late-token"); + await Task.Delay(25, TestContext.Current.CancellationToken); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task CallerCancellationInterruptsANonCooperativeHttpHandlerAndDisposesItsLateResponse() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pendingResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new CaptureHandler((_, _) => + { + entered.TrySetResult(true); + return pendingResponse.Task; + }); + var provider = Provider(handler); + using var cancellation = new CancellationTokenSource(); + var operation = CollectAsync(provider, Request(), cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await operation.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)); + var content = new TrackingContent(); + pendingResponse.TrySetResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + await content.DisposedTask.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + Assert.True(content.Disposed); + } + + [Fact] + public async Task CallerCancellationInterruptsANonCooperativeResponseRead() + { + var stream = new NonCooperativeReadStream(); + var handler = new CaptureHandler((_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream), + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/event-stream"); + return Task.FromResult(response); + }); + using var cancellation = new CancellationTokenSource(); + var operation = CollectAsync(Provider(handler), Request(), cancellation.Token); + await stream.ReadStarted.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await operation.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)); + Assert.True(stream.Disposed); + stream.Release(); + } + + [Fact] + public async Task CompletesAtTerminalEventWithoutWaitingForConnectionClose() + { + var prefix = Encoding.UTF8.GetBytes(Event(Done("stop"))); + var stream = new PrefixThenWaitStream(prefix); + var handler = new CaptureHandler((_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream), + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/event-stream"); + return Task.FromResult(response); + }); + + var events = await CollectAsync( + Provider(handler), + Request(), + TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Completed, Assert.Single(events).Kind); + Assert.True(stream.Disposed); + } + + [Fact] + public void ValidatesCapabilitiesEndpointsHeadersAndToolChoice() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var provider = Provider(handler); + var capabilities = Assert.IsAssignableFrom(provider); + Assert.Equal(new[] { "message-gateway" }, capabilities.SupportedApis); + Assert.False(capabilities.SupportsDeferredResponses); + Assert.False(capabilities.SupportsNativeDeferredTools); + + Assert.Throws(() => + new MessageGatewayProvider(new MessageGatewayProviderOptions( + new HttpClient(handler), + new Uri("http://remote.example/v1")))); + + var insecure = new MessageGatewayProviderOptions( + new HttpClient(handler), + new Uri("http://remote.example/v1")) + { + AccessToken = "token", + AllowInsecureHttp = true, + }; + _ = new MessageGatewayProvider(insecure); + + var invalidHeader = Options(handler); + invalidHeader.Headers["Bad Header"] = "value"; + Assert.Throws(() => new MessageGatewayProvider(invalidHeader)); + + var invalidAuthorization = Options(handler); + invalidAuthorization.Headers["Authorization"] = " "; + Assert.Throws(() => new MessageGatewayProvider(invalidAuthorization)); + + var invalidToolChoice = Options(handler); + invalidToolChoice.ToolChoice = MessageGatewayToolChoiceMode.Function; + Assert.Throws(() => new MessageGatewayProvider(invalidToolChoice)); + + var invalidSnapshotBudget = Options(handler); + invalidSnapshotBudget.MaxPartialSnapshotWork = 0; + Assert.Throws(() => new MessageGatewayProvider(invalidSnapshotBudget)); + } + + [Fact] + public async Task UnsupportedTransportAndDeferredModeFailInBand() + { + var handler = new CaptureHandler((_, _) => Task.FromResult(SseResponse(Done("stop")))); + var provider = Provider(handler); + var websocket = new ModelParameters { Transport = ModelTransport.WebSocket }; + var deferred = new ModelParameters { Deferred = true }; + + var websocketEvents = await CollectAsync( + provider, + Request(parameters: websocket), + TestContext.Current.CancellationToken); + var deferredEvents = await CollectAsync( + provider, + Request(parameters: deferred), + TestContext.Current.CancellationToken); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(websocketEvents).Kind); + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(deferredEvents).Kind); + Assert.Equal(0, handler.RequestCount); + } + + private static MessageGatewayProvider Provider(CaptureHandler handler) + { + var options = Options(handler); + options.AccessToken = "token"; + return new MessageGatewayProvider(options); + } + + private static MessageGatewayProviderOptions Options(CaptureHandler handler) => new( + new HttpClient(handler), + new Uri("https://gateway.example/v1")); + + private static ModelRequest Request( + IReadOnlyList? messages = null, + ModelParameters? parameters = null) => new( + "world-model", + string.Empty, + messages ?? Array.Empty(), + Array.Empty(), + parameters ?? new ModelParameters(), + null, + "run-1", + 1); + + private static async Task> CollectAsync( + IModelProvider provider, + ModelRequest request, + CancellationToken cancellationToken) + { + var events = new List(); + await foreach (var item in provider.StreamAsync(request, cancellationToken)) + { + events.Add(item); + } + + return events; + } + + private static HttpResponseMessage SseResponse(params string[] events) => new(HttpStatusCode.OK) + { + Content = new StringContent( + string.Concat(events.Select(Event)), + Encoding.UTF8, + "text/event-stream"), + }; + + private static string Event(string value) => "data: " + value + "\n\n"; + + private static string Done(string reason, string? responseId = null, bool rewrite = false) + { + var response = responseId is null ? string.Empty : ",\"responseId\":\"" + responseId + "\""; + var rewriteValue = rewrite + ? ",\"rewrite\":{\"policyId\":\"context-policy\",\"policyVersion\":2,\"changed\":true,\"tokenCountChange\":-4,\"messageCountChange\":-1,\"systemPromptChanged\":false}" + : string.Empty; + return "{\"type\":\"done\",\"reason\":\"" + reason + "\",\"usage\":" + Usage() + response + rewriteValue + "}"; + } + + private static string Usage() => + "{\"input\":1,\"output\":2,\"cacheRead\":3,\"cacheWrite\":4,\"reasoning\":1,\"cacheWrite1h\":2,\"totalTokens\":10,\"cost\":{\"input\":0.1,\"output\":0.2,\"cacheRead\":0.3,\"cacheWrite\":0.4,\"total\":1.0}}"; + + private static string EmptyUsage() => + "{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}}"; + + private sealed class CaptureHandler : HttpMessageHandler + { + private readonly Func> _respond; + + public CaptureHandler(Func> respond) + { + _respond = respond; + } + + public int RequestCount { get; private set; } + + public HttpMethod? Method { get; private set; } + + public Uri? Uri { get; private set; } + + public string? Authorization { get; private set; } + + public string? Accept { get; private set; } + + public string? ContentType { get; private set; } + + public string? Body { get; private set; } + + public IReadOnlyDictionary Headers { get; private set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestCount++; + Method = request.Method; + Uri = request.RequestUri; + Authorization = request.Headers.TryGetValues("Authorization", out var authorization) + ? authorization.Single() + : null; + Accept = request.Headers.Accept.SingleOrDefault()?.MediaType; + ContentType = request.Content?.Headers.ContentType?.MediaType; + Headers = request.Headers.ToDictionary( + pair => pair.Key, + pair => string.Join(",", pair.Value), + StringComparer.OrdinalIgnoreCase); + Body = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return await _respond(request, cancellationToken); + } + } + + private sealed class PrefixThenWaitStream : Stream + { + private readonly byte[] _prefix; + private readonly CancellationTokenSource _disposed = new(); + private int _offset; + + public PrefixThenWaitStream(byte[] prefix) + { + _prefix = prefix; + } + + public bool Disposed { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_offset < _prefix.Length) + { + var copied = Math.Min(count, _prefix.Length - _offset); + Array.Copy(_prefix, _offset, buffer, offset, copied); + _offset += copied; + return copied; + } + + _disposed.Token.WaitHandle.WaitOne(); + return 0; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + if (_offset < _prefix.Length) + { + return Read(buffer, offset, count); + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _disposed.Token); + try + { + await Task.Delay(Timeout.Infinite, linked.Token); + } + catch (OperationCanceledException) when (_disposed.IsCancellationRequested) + { + return 0; + } + + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && !Disposed) + { + Disposed = true; + _disposed.Cancel(); + _disposed.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class TrackingContent : HttpContent + { + private readonly TaskCompletionSource _disposed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool Disposed { get; private set; } + + public Task DisposedTask => _disposed.Task; + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !Disposed) + { + Disposed = true; + _disposed.TrySetResult(true); + } + + base.Dispose(disposing); + } + } + + private sealed class NonCooperativeReadStream : Stream + { + private readonly TaskCompletionSource _read = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ReadStarted => _started.Task; + + public bool Disposed { get; private set; } + + public void Release() => _read.TrySetResult(0); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + _started.TrySetResult(true); + return _read.Task; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Disposed = true; + } + + base.Dispose(disposing); + } + } +} diff --git a/tests/OpenGameAgent.Providers.MessageGateway.Tests/OpenGameAgent.Providers.MessageGateway.Tests.csproj b/tests/OpenGameAgent.Providers.MessageGateway.Tests/OpenGameAgent.Providers.MessageGateway.Tests.csproj new file mode 100644 index 0000000..ec8bc48 --- /dev/null +++ b/tests/OpenGameAgent.Providers.MessageGateway.Tests/OpenGameAgent.Providers.MessageGateway.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.MessageGateway.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json new file mode 100644 index 0000000..bac04c5 --- /dev/null +++ b/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json @@ -0,0 +1,230 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.messagegateway": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.Mistral.Tests/MistralConversationsProviderTests.cs b/tests/OpenGameAgent.Providers.Mistral.Tests/MistralConversationsProviderTests.cs new file mode 100644 index 0000000..e435918 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Mistral.Tests/MistralConversationsProviderTests.cs @@ -0,0 +1,334 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Providers.Mistral.Tests; + +public sealed class MistralConversationsProviderTests +{ + [Fact] + public async Task StreamsThinkingTextIncrementalToolsAndCachedUsage() + { + const string stream = """ + data: {"id":"response-1","model":"served-model","choices":[{"delta":{"content":[{"type":"thinking","thinking":[{"type":"text","text":"plan"}]},{"type":"text","text":"hello"}],"tool_calls":[{"index":0,"id":"D681PevKs","function":{"name":"move","arguments":"{\"x\":"}}]}}]} + + data: {"id":"response-1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16,"prompt_tokens_details":{"cached_tokens":2}}} + + data: [DONE] + + """; + var provider = Create(new StubHandler(_ => Response(stream))); + + var events = await CollectAsync(provider.StreamAsync(Request("devstral-medium-latest"), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("served-model", response.ResponseModel); + Assert.Equal("tool_calls", response.RawStopReason); + Assert.Equal("plan", Assert.IsType(response.Content[0]).Text); + Assert.Equal("hello", Assert.IsType(response.Content[1]).Text); + var call = Assert.IsType(response.Content[2]); + Assert.Equal("D681PevKs", call.Id); + Assert.Equal("move", call.Name); + Assert.Equal("{\"x\":1}", call.ArgumentsJson); + Assert.Equal(10, response.Usage.InputTokens); + Assert.Equal(2, response.Usage.CacheReadTokens); + Assert.Equal(4, response.Usage.OutputTokens); + Assert.Contains(events, value => value.Kind == ModelStreamEventKind.ToolCallEnded); + + var reasoningEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded); + Assert.Equal(Assert.IsType(response.Content[0]).Text, reasoningEnded.Content); + Assert.Equal( + Assert.IsType(response.Content[0]).Text, + Assert.IsType(reasoningEnded.Partial!.Content[reasoningEnded.ContentIndex]).Text); + + var textEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.TextEnded); + Assert.Equal(Assert.IsType(response.Content[1]).Text, textEnded.Content); + Assert.Equal( + Assert.IsType(response.Content[1]).Text, + Assert.IsType(textEnded.Partial!.Content[textEnded.ContentIndex]).Text); + + var toolStarted = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallStarted); + var toolDeltas = events.Where(item => item.Kind == ModelStreamEventKind.ToolCallDelta).ToArray(); + Assert.NotEmpty(toolDeltas); + var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + var toolEvents = events.Where(item => item.Kind is + ModelStreamEventKind.ToolCallStarted or + ModelStreamEventKind.ToolCallDelta or + ModelStreamEventKind.ToolCallEnded); + Assert.All(toolEvents, item => + { + Assert.Equal(toolStarted.ContentIndex, item.ContentIndex); + var partialToolCall = Assert.IsType(item.Partial!.Content[item.ContentIndex]); + AssertJsonObject(partialToolCall.ArgumentsJson); + }); + + var terminalToolCall = Assert.IsType(response.Content[2]); + var endedToolCall = Assert.IsType(toolEnded.ToolCall); + var endedPartialToolCall = Assert.IsType(toolEnded.Partial!.Content[toolEnded.ContentIndex]); + Assert.Equal(terminalToolCall.Id, endedToolCall.Id); + Assert.Equal(terminalToolCall.Name, endedToolCall.Name); + Assert.Equal("{\"x\":1}", endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ArgumentsJson, endedToolCall.ArgumentsJson); + Assert.Equal(terminalToolCall.ThoughtSignature, endedToolCall.ThoughtSignature); + Assert.Equal(terminalToolCall.Namespace, endedToolCall.Namespace); + Assert.Equal(endedToolCall.Id, toolEnded.ToolCallId); + Assert.Equal(endedToolCall.Name, toolEnded.ToolName); + AssertToolCallEqual(endedToolCall, endedPartialToolCall); + } + + [Fact] + public async Task SerializesCachingStrictToolsImagesAndCrossProviderIds() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + options.ToolChoice = MistralToolChoice.Function; + options.RequiredToolName = "inspect"; + var provider = new MistralConversationsProvider(options); + var tool = new ToolDefinition( + "inspect", + "Inspect", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}}}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)); + var call = new ToolCallContent("foreign|long|call", "inspect", "{\"x\":1}"); + var request = new ModelRequest( + "mistral-large-latest", + "rules", + new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { new ReasoningContent("plan"), call }, + DateTimeOffset.UnixEpoch, + model: "other-model", + stopReason: ModelStopReason.ToolUse, + provider: "other", + api: "other-api"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] + { + new TextContent("clear"), + new BinaryContent(AgentMediaKind.Image, "dG9vbA==", "image/png"), + }), + DateTimeOffset.UnixEpoch), + }, + new[] { tool }, + new ModelParameters { ReasoningLevel = "high", CacheRetention = ModelCacheRetention.Short }, + "session-123", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal("session-123", handler.Affinity); + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.Equal("session-123", root.GetProperty("prompt_cache_key").GetString()); + Assert.Equal("reasoning", root.GetProperty("prompt_mode").GetString()); + Assert.Equal("inspect", root.GetProperty("tool_choice").GetProperty("function").GetProperty("name").GetString()); + Assert.True(root.GetProperty("tools")[0].GetProperty("function").GetProperty("strict").GetBoolean()); + Assert.Contains("aW1hZ2U=", handler.RequestBody, StringComparison.Ordinal); + var normalized = root.GetProperty("messages")[2].GetProperty("tool_calls")[0].GetProperty("id").GetString(); + Assert.NotNull(normalized); + Assert.Equal(9, normalized!.Length); + Assert.DoesNotContain("|", normalized, StringComparison.Ordinal); + Assert.Equal(normalized, root.GetProperty("messages")[3].GetProperty("tool_call_id").GetString()); + } + + [Theory] + [InlineData("mistral-small-latest", "reasoning_effort")] + [InlineData("mistral-medium-3.5", "reasoning_effort")] + [InlineData("magistral-medium-latest", "prompt_mode")] + public async Task SelectsModelSpecificReasoningControl(string model, string expectedProperty) + { + var handler = new StubHandler(_ => Response(StopStream())); + var provider = Create(handler); + var request = new ModelRequest( + model, + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters { ReasoningLevel = "medium" }, + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + Assert.True(document.RootElement.TryGetProperty(expectedProperty, out _)); + } + + [Fact] + public async Task PreservesUnknownFinishReasonAsFailedTerminal() + { + var provider = Create(new StubHandler(_ => Response(""" + data: {"id":"response-1","choices":[{"delta":{},"finish_reason":"unmapped_error"}]} + + """))); + + var events = await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStreamEventKind.Failed, events.Last().Kind); + Assert.Equal(ModelStopReason.Error, response.StopReason); + Assert.Equal("unmapped_error", response.RawStopReason); + Assert.Equal("Provider stopped with: unmapped_error", response.ErrorMessage); + } + + [Fact] + public async Task RejectsStreamWithoutFinishReason() + { + var provider = Create(new StubHandler(_ => Response(""" + data: {"id":"response-1","choices":[{"delta":{"content":"hello"}}]} + + """))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken))); + Assert.Contains("finish reason", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task BadResponseObserverCannotBreakSuccessfulStream() + { + var options = Options(new HttpClient(new StubHandler(_ => Response(StopStream())))); + options.ResponseObserver = (_, _) => throw new InvalidOperationException("observer failed"); + var provider = new MistralConversationsProvider(options); + + var events = await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStopReason.Stop, events.Last().Response!.StopReason); + } + + [Fact] + public async Task TombstoneSuppressesOptionalAffinityButCannotDeleteCredential() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + options.Headers["x-affinity"] = null; + options.Headers["Authorization"] = null; + var request = new ModelRequest( + "model", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters { CacheRetention = ModelCacheRetention.Short }, + "session", + "run", + 1); + + await CollectAsync(new MistralConversationsProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken)); + + Assert.Null(handler.Affinity); + Assert.Equal("Bearer test-key", handler.Authorization); + } + + [Fact] + public async Task ResponseObserverIsSnapshottedAtConstruction() + { + var handler = new StubHandler(_ => Response(StopStream())); + var options = Options(new HttpClient(handler)); + var original = 0; + var replacement = 0; + options.ResponseObserver = (_, _) => + { + Interlocked.Increment(ref original); + return default; + }; + var provider = new MistralConversationsProvider(options); + options.ResponseObserver = (_, _) => + { + Interlocked.Increment(ref replacement); + return default; + }; + + await CollectAsync(provider.StreamAsync(Request("model"), TestContext.Current.CancellationToken)); + + Assert.Equal(1, original); + Assert.Equal(0, replacement); + } + + private static MistralConversationsProvider Create(HttpMessageHandler handler) => + new(Options(new HttpClient(handler))); + + private static MistralConversationsProviderOptions Options(HttpClient client) => + new(client, new Uri("https://api.mistral.ai/v1/chat/completions")) { ApiKey = "test-key" }; + + private static ModelRequest Request(string model) => + new(model, string.Empty, Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); + + private static string StopStream() => """ + data: {"id":"response-1","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} + + """; + + private static HttpResponseMessage Response(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }; + + private static async Task> CollectAsync(IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var item in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + events.Add(item); + } + + return events; + } + + private static void AssertJsonObject(string value) + { + using var document = JsonDocument.Parse(value); + Assert.Equal(JsonValueKind.Object, document.RootElement.ValueKind); + } + + private static void AssertToolCallEqual(ToolCallContent expected, ToolCallContent actual) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.ArgumentsJson, actual.ArgumentsJson); + Assert.Equal(expected.ThoughtSignature, actual.ThoughtSignature); + Assert.Equal(expected.Namespace, actual.Namespace); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _response; + + public StubHandler(Func response) + { + _response = response; + } + + public string? RequestBody { get; private set; } + + public string? Affinity { get; private set; } + + public string? Authorization { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + Affinity = request.Headers.TryGetValues("x-affinity", out var values) ? values.Single() : null; + Authorization = request.Headers.Authorization?.ToString(); + return _response(request); + } + } +} diff --git a/tests/OpenGameAgent.Providers.Mistral.Tests/OpenGameAgent.Providers.Mistral.Tests.csproj b/tests/OpenGameAgent.Providers.Mistral.Tests/OpenGameAgent.Providers.Mistral.Tests.csproj new file mode 100644 index 0000000..50416d3 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Mistral.Tests/OpenGameAgent.Providers.Mistral.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.Mistral.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json new file mode 100644 index 0000000..0a68ba2 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json @@ -0,0 +1,224 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.mistral": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/AzureOpenAIResponsesTests.cs b/tests/OpenGameAgent.Providers.OpenAI.Tests/AzureOpenAIResponsesTests.cs new file mode 100644 index 0000000..6da6170 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/AzureOpenAIResponsesTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Text; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Providers.OpenAI.Tests; + +public sealed class AzureOpenAIResponsesTests +{ + [Theory] + [InlineData("https://example.openai.azure.com", "https://example.openai.azure.com/openai/v1/responses?api-version=v1")] + [InlineData("https://example.cognitiveservices.azure.com/openai", "https://example.cognitiveservices.azure.com/openai/v1/responses?api-version=v1")] + [InlineData("https://example.ai.azure.com/openai/v1/responses?old=true", "https://example.ai.azure.com/openai/v1/responses?api-version=v1")] + [InlineData("https://proxy.example.test/v1?custom=true", "https://proxy.example.test/v1/responses?custom=true")] + public void NormalizesHostedAndProxyEndpoints(string input, string expected) + { + Assert.Equal(expected, AzureOpenAIResponses.BuildResponsesEndpoint(input).AbsoluteUri); + } + + [Fact] + public async Task SendsApiKeyHeaderAndDeploymentModel() + { + var handler = new CaptureHandler(); + var options = AzureOpenAIResponses.CreateOptions( + new HttpClient(handler), + "https://example.openai.azure.com", + "secret", + "2025-04-01-preview"); + var provider = new OpenAIResponsesProvider(options); + var request = new ModelRequest( + "deployment-name", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + await foreach (var _ in provider.StreamAsync(request, TestContext.Current.CancellationToken)) + { + } + + Assert.Equal("secret", handler.ApiKey); + Assert.Null(handler.Authorization); + Assert.Contains("\"model\":\"deployment-name\"", handler.Body, StringComparison.Ordinal); + Assert.Equal("2025-04-01-preview", ParseQuery(handler.RequestUri!).Single(value => value.Key == "api-version").Value); + } + + private static IEnumerable> ParseQuery(Uri uri) + { + foreach (var part in uri.Query.TrimStart('?').Split('&')) + { + var pieces = part.Split(new[] { '=' }, 2); + yield return new KeyValuePair( + Uri.UnescapeDataString(pieces[0]), + Uri.UnescapeDataString(pieces[1])); + } + } + + private sealed class CaptureHandler : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + + public string? ApiKey { get; private set; } + + public string? Authorization { get; private set; } + + public string? Body { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + ApiKey = request.Headers.TryGetValues("api-key", out var keys) ? keys.Single() : null; + Authorization = request.Headers.Authorization?.ToString(); + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response\",\"model\":\"model\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + Encoding.UTF8, + "text/event-stream"), + }; + } + } +} diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexResponsesTests.cs b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexResponsesTests.cs new file mode 100644 index 0000000..0ccb888 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexResponsesTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Providers.OpenAI.Tests; + +public sealed class OpenAICodexResponsesTests +{ + [Fact] + public async Task SendsAccountScopedHeadersAndCodexRequestShape() + { + var handler = new CaptureHandler(); + var token = Token("account-one"); + var provider = new OpenAIResponsesProvider(OpenAICodexResponses.CreateOptions( + new HttpClient(handler), + token)); + var request = new ModelRequest( + "game-model", + "Act as the world simulation agent.", + new[] { AgentMessage.User("advance", DateTimeOffset.UnixEpoch) }, + Array.Empty(), + new ModelParameters + { + ReasoningLevel = "high", + Transport = ModelTransport.ServerSentEvents, + }, + "session-one", + "run", + 1); + + await foreach (var _ in provider.StreamAsync(request, TestContext.Current.CancellationToken)) + { + } + + Assert.Equal("Bearer " + token, handler.Authorization); + Assert.Equal("account-one", handler.AccountId); + Assert.Equal("responses=experimental", handler.Beta); + Assert.Equal("opengameagent", handler.Originator); + Assert.Equal("session-one", handler.SessionId); + using var document = JsonDocument.Parse(handler.Body!); + var root = document.RootElement; + Assert.Equal("Act as the world simulation agent.", root.GetProperty("instructions").GetString()); + Assert.Equal("auto", root.GetProperty("tool_choice").GetString()); + Assert.True(root.GetProperty("parallel_tool_calls").GetBoolean()); + Assert.Equal("low", root.GetProperty("text").GetProperty("verbosity").GetString()); + Assert.Equal("high", root.GetProperty("reasoning").GetProperty("effort").GetString()); + Assert.Equal("reasoning.encrypted_content", root.GetProperty("include")[0].GetString()); + Assert.DoesNotContain(root.GetProperty("input").EnumerateArray(), item => + item.TryGetProperty("role", out var role) + && role.GetString() is "system" or "developer"); + } + + [Fact] + public async Task ResolvesTokenAndAccountTogetherForEveryRequest() + { + var handler = new CaptureHandler(); + var calls = 0; + var options = OpenAICodexResponses.CreateOptions( + new HttpClient(handler), + _ => new ValueTask(new OpenAIRequestCredential( + Token(calls++ == 0 ? "account-one" : "account-two")))); + var provider = new OpenAIResponsesProvider(options); + + await DrainAsync(provider, Request("one")); + Assert.Equal("account-one", handler.AccountId); + await DrainAsync(provider, Request("two")); + Assert.Equal("account-two", handler.AccountId); + Assert.Equal(2, calls); + } + + [Fact] + public void RejectsTokensWithoutTheAccountClaim() + { + var token = TokenPayload(new Dictionary { ["sub"] = "user" }); + + Assert.Throws(() => OpenAICodexResponses.ExtractAccountId(token)); + } + + private static ModelRequest Request(string runId) => new( + "model", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters { Transport = ModelTransport.ServerSentEvents }, + null, + runId, + 1); + + private static async Task DrainAsync(IModelProvider provider, ModelRequest request) + { + await foreach (var _ in provider.StreamAsync(request, TestContext.Current.CancellationToken)) + { + } + } + + private static string Token(string accountId) => TokenPayload(new Dictionary + { + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_account_id"] = accountId, + }, + }); + + private static string TokenPayload(IReadOnlyDictionary payload) => + Base64Url("{\"alg\":\"none\"}") + "." + + Base64Url(JsonSerializer.Serialize(payload)) + ".signature"; + + private static string Base64Url(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private sealed class CaptureHandler : HttpMessageHandler + { + public string? Authorization { get; private set; } + + public string? AccountId { get; private set; } + + public string? Beta { get; private set; } + + public string? Originator { get; private set; } + + public string? SessionId { get; private set; } + + public string? Body { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Authorization = request.Headers.Authorization?.ToString(); + AccountId = Header(request, "chatgpt-account-id"); + Beta = Header(request, "OpenAI-Beta"); + Originator = Header(request, "originator"); + SessionId = Header(request, "session-id"); + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"response\",\"model\":\"model\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + Encoding.UTF8, + "text/event-stream"), + }; + } + + private static string? Header(HttpRequestMessage request, string name) => + request.Headers.TryGetValues(name, out var values) ? values.Single() : null; + } +} diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexWebSocketTests.cs b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexWebSocketTests.cs new file mode 100644 index 0000000..54454a7 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAICodexWebSocketTests.cs @@ -0,0 +1,641 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.OpenAI.Tests; + +public sealed class OpenAICodexWebSocketTests +{ + [Fact] + public async Task NewWebSocketHandshakePublishesOnlySanitizedResponseMetadata() + { + ProviderResponseObservation? observed = null; + var http = new CountingHandler(); + var connection = new ScriptedConnection( + (_, _) => new object[] { Completed("resp_1") }, + new Dictionary + { + ["x-request-id"] = "ws-request", + ["set-cookie"] = "credential=secret", + }); + var factory = new QueueFactory(connection); + var options = OpenAICodexResponses.CreateOptions(new HttpClient(http), Token("account-one")); + options.WebSocketConnectionFactory = factory; + options.ResponseObserver = (observation, _) => + { + observed = observation; + return default; + }; + using var provider = new OpenAIResponsesProvider(options); + + var events = await CollectAsync(provider.StreamAsync( + Request( + new[] { AgentMessage.User("hello", DateTimeOffset.UnixEpoch) }, + ModelTransport.WebSocket, + ModelCacheRetention.None, + null), + TestContext.Current.CancellationToken)); + + Assert.True(events[^1].IsTerminal); + Assert.NotNull(observed); + Assert.Equal(101, observed.StatusCode); + Assert.Equal("ws-request", observed.Metadata["x-request-id"]); + Assert.Single(observed.Metadata); + } + + [Fact] + public async Task AutoUsesOneShotWebSocketAndCodexHeadersWhenCachingIsDisabled() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((_, _) => new object[] { Completed("resp_1") }); + var factory = new QueueFactory(connection); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + new[] { AgentMessage.User("hello", DateTimeOffset.UnixEpoch) }, + ModelTransport.Auto, + ModelCacheRetention.None, + "ignored-session"); + + var events = await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.True(events[^1].IsTerminal); + Assert.Equal(0, http.Calls); + Assert.Equal(1, factory.Calls); + Assert.Equal("responses_websockets=2026-02-06", factory.Requests[0].Headers["OpenAI-Beta"]); + Assert.Equal("account-one", factory.Requests[0].Headers["chatgpt-account-id"]); + Assert.NotEqual("ignored-session", factory.Requests[0].Headers["session-id"]); + Assert.Equal(1, connection.DisposeCalls); + using var body = JsonDocument.Parse(connection.SentBodies[0]); + Assert.Equal("response.create", body.RootElement.GetProperty("type").GetString()); + Assert.False(body.RootElement.TryGetProperty("prompt_cache_key", out _)); + } + + [Fact] + public async Task CachedWebSocketReusesAccountConnectionAndSendsOnlyInputDelta() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((send, _) => send == 1 + ? TextResponse("resp_1", "msg_1", "hello") + : new object[] { Completed("resp_2") }); + var factory = new QueueFactory(connection); + var observerCalls = 0; + using var provider = Provider( + http, + factory, + "account-one", + (_, _) => + { + Interlocked.Increment(ref observerCalls); + return default; + }); + var firstUser = AgentMessage.User("start", DateTimeOffset.UnixEpoch); + var firstRequest = Request( + new[] { firstUser }, + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "session-one"); + + var firstEvents = await CollectAsync( + provider.StreamAsync(firstRequest, TestContext.Current.CancellationToken)); + var firstResponse = firstEvents[^1].Response!; + var assistant = Assistant(firstResponse, firstRequest.Model); + var secondRequest = Request( + new[] + { + firstUser, + assistant, + AgentMessage.User("finish", DateTimeOffset.UnixEpoch.AddSeconds(1)), + }, + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "session-one"); + + await CollectAsync(provider.StreamAsync(secondRequest, TestContext.Current.CancellationToken)); + + Assert.Equal(0, http.Calls); + Assert.Equal(1, factory.Calls); + Assert.Equal(2, connection.SentBodies.Count); + using var secondBody = JsonDocument.Parse(connection.SentBodies[1]); + var root = secondBody.RootElement; + Assert.Equal("resp_1", root.GetProperty("previous_response_id").GetString()); + var delta = root.GetProperty("input"); + Assert.Single(delta.EnumerateArray()); + Assert.Equal("finish", delta[0].GetProperty("content")[0].GetProperty("text").GetString()); + var statistics = provider.GetWebSocketStatistics("session-one")!; + Assert.Equal(2, statistics.Requests); + Assert.Equal(1, statistics.ConnectionsCreated); + Assert.Equal(1, statistics.ConnectionsReused); + Assert.Equal(1, statistics.FullContextRequests); + Assert.Equal(1, statistics.DeltaRequests); + Assert.Equal(1, observerCalls); + } + + [Fact] + public async Task FailureBeforeOutputFallsBackAndPinsSessionToSse() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((_, _) => new object[] { new IOException("connect path failed") }); + var factory = new QueueFactory(connection); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + new[] { AgentMessage.User("hello", DateTimeOffset.UnixEpoch) }, + ModelTransport.Auto, + ModelCacheRetention.Short, + "fallback-session"); + + var first = await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + var second = await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal(1, factory.Calls); + Assert.Equal(2, http.Calls); + Assert.Contains(first[^1].Response!.Diagnostics, value => value.Code == "provider_transport_fallback"); + Assert.Contains(second[^1].Response!.Diagnostics, value => value.Code == "provider_transport_fallback"); + var statistics = provider.GetWebSocketStatistics("fallback-session")!; + Assert.Equal(1, statistics.Failures); + Assert.Equal(2, statistics.SseFallbacks); + Assert.True(statistics.FallbackActive); + } + + [Fact] + public async Task FailureAfterOutputNeverReplaysOverSse() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((_, _) => new object[] + { + JsonSerializer.Serialize(new + { + type = "response.output_item.added", + output_index = 0, + item = new { type = "message", id = "msg_1", role = "assistant", status = "in_progress" }, + }), + new IOException("stream failed"), + }); + var factory = new QueueFactory(connection); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + new[] { AgentMessage.User("hello", DateTimeOffset.UnixEpoch) }, + ModelTransport.Auto, + ModelCacheRetention.Short, + "started-session"); + + var error = await Assert.ThrowsAnyAsync(async () => + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken))); + + Assert.Equal("stream failed", error.Message); + Assert.Equal(0, http.Calls); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public async Task ConnectionLimitBeforeOutputReconnectsExactlyOnce() + { + var http = new CountingHandler(); + var limited = new ScriptedConnection((_, _) => new object[] + { + JsonSerializer.Serialize(new + { + type = "error", + error = new { code = "websocket_connection_limit_reached", message = "limit" }, + }), + }); + var succeeding = new ScriptedConnection((_, _) => new object[] { Completed("resp_1") }); + var factory = new QueueFactory(limited, succeeding); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + Array.Empty(), + ModelTransport.WebSocket, + ModelCacheRetention.None, + sessionId: null); + + var events = await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.True(events[^1].IsTerminal); + Assert.Equal(2, factory.Calls); + Assert.Equal(0, http.Calls); + Assert.Equal(1, limited.DisposeCalls); + Assert.Equal(1, succeeding.DisposeCalls); + } + + [Fact] + public async Task CachedConnectionsAreScopedToTheAuthenticatedAccount() + { + var http = new CountingHandler(); + var firstAccount = new ScriptedConnection((send, _) => new object[] { Completed("a-" + send) }); + var secondAccount = new ScriptedConnection((send, _) => new object[] { Completed("b-" + send) }); + var factory = new QueueFactory(firstAccount, secondAccount); + var credentialCall = 0; + var options = OpenAICodexResponses.CreateOptions( + new HttpClient(http), + _ => new ValueTask(new OpenAIRequestCredential( + Token(credentialCall++ == 1 ? "account-two" : "account-one")))); + options.WebSocketConnectionFactory = factory; + using var provider = new OpenAIResponsesProvider(options); + var request = Request( + Array.Empty(), + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "shared-session"); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal(2, factory.Calls); + Assert.Equal("account-one", factory.Requests[0].Headers["chatgpt-account-id"]); + Assert.Equal("account-two", factory.Requests[1].Headers["chatgpt-account-id"]); + Assert.Equal(2, firstAccount.SentBodies.Count); + Assert.Single(secondAccount.SentBodies); + var statistics = provider.GetWebSocketStatistics("shared-session")!; + Assert.Equal(2, statistics.ConnectionsCreated); + Assert.Equal(1, statistics.ConnectionsReused); + } + + [Fact] + public async Task MissingCachedContinuationRetriesWithFullContext() + { + var http = new CountingHandler(); + var cached = new ScriptedConnection((send, _) => send == 1 + ? TextResponse("resp_1", "msg_1", "hello") + : new object[] + { + JsonSerializer.Serialize(new + { + type = "error", + error = new { code = "previous_response_not_found", message = "missing" }, + }), + }); + var recovered = new ScriptedConnection((_, _) => new object[] { Completed("resp_2") }); + var factory = new QueueFactory(cached, recovered); + using var provider = Provider(http, factory, "account-one"); + var firstUser = AgentMessage.User("start", DateTimeOffset.UnixEpoch); + var firstRequest = Request( + new[] { firstUser }, + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "recovery-session"); + var first = (await CollectAsync(provider.StreamAsync( + firstRequest, + TestContext.Current.CancellationToken)))[^1].Response!; + var secondRequest = Request( + new[] + { + firstUser, + Assistant(first, firstRequest.Model), + AgentMessage.User("finish", DateTimeOffset.UnixEpoch.AddSeconds(1)), + }, + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "recovery-session"); + + var second = await CollectAsync(provider.StreamAsync( + secondRequest, + TestContext.Current.CancellationToken)); + + Assert.True(second[^1].IsTerminal); + Assert.Equal(2, factory.Calls); + Assert.Equal(2, cached.SentBodies.Count); + Assert.Single(recovered.SentBodies); + using var delta = JsonDocument.Parse(cached.SentBodies[1]); + Assert.Equal("resp_1", delta.RootElement.GetProperty("previous_response_id").GetString()); + using var full = JsonDocument.Parse(recovered.SentBodies[0]); + Assert.False(full.RootElement.TryGetProperty("previous_response_id", out _)); + Assert.Equal(3, full.RootElement.GetProperty("input").GetArrayLength()); + Assert.Equal(0, http.Calls); + } + + [Fact] + public async Task StoppingEnumerationEarlyReleasesTheCachedConnection() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((_, _) => TextResponse("resp_1", "msg_1", "hello")); + var factory = new QueueFactory(connection); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + new[] { AgentMessage.User("hello", DateTimeOffset.UnixEpoch) }, + ModelTransport.CachedWebSocket, + ModelCacheRetention.Short, + "early-stop"); + + await using (var enumerator = provider.StreamAsync( + request, + TestContext.Current.CancellationToken) + .GetAsyncEnumerator(TestContext.Current.CancellationToken)) + { + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(ModelStreamEventKind.Started, enumerator.Current.Kind); + } + + Assert.Equal(1, connection.DisposeCalls); + Assert.Equal(0, http.Calls); + } + + [Fact] + public async Task ProviderProtocolFailureBeforeOutputDoesNotReplayOverSse() + { + var http = new CountingHandler(); + var connection = new ScriptedConnection((_, _) => new object[] + { + JsonSerializer.Serialize(new + { + type = "response.failed", + response = new + { + status = "failed", + error = new { code = "usage_limit_reached", message = "quota exhausted" }, + }, + }), + }); + var factory = new QueueFactory(connection); + using var provider = Provider(http, factory, "account-one"); + var request = Request( + Array.Empty(), + ModelTransport.Auto, + ModelCacheRetention.Short, + "protocol-failure"); + + var error = await Assert.ThrowsAnyAsync(async () => + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken))); + + Assert.Contains("usage_limit_reached", error.Message, StringComparison.Ordinal); + Assert.Equal(0, http.Calls); + Assert.Equal(1, factory.Calls); + var statistics = provider.GetWebSocketStatistics("protocol-failure")!; + Assert.Equal(0, statistics.Failures); + Assert.False(statistics.FallbackActive); + } + + [Fact] + public async Task NonCooperativeConnectTimesOutAndDisposesLateConnection() + { + var http = new CountingHandler(); + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var lateConnection = new ScriptedConnection((_, _) => new object[] { Completed("late") }); + var options = OpenAICodexResponses.CreateOptions(new HttpClient(http), Token("account-one")); + options.WebSocketConnectionFactory = (_, _) => new ValueTask(pending.Task); + using var provider = new OpenAIResponsesProvider(options); + var request = Request( + Array.Empty(), + ModelTransport.Auto, + ModelCacheRetention.Short, + "connect-timeout"); + request.Parameters.WebSocketConnectTimeoutMilliseconds = 20; + + var events = await CollectAsync(provider.StreamAsync( + request, + TestContext.Current.CancellationToken)); + pending.SetResult(lateConnection); + for (var attempt = 0; attempt < 100 && lateConnection.DisposeCalls == 0; attempt++) + { + await Task.Delay(1, TestContext.Current.CancellationToken); + } + + Assert.True(events[^1].IsTerminal); + Assert.Equal(1, http.Calls); + Assert.Equal(1, lateConnection.DisposeCalls); + Assert.Contains(events[^1].Response!.Diagnostics, value => value.Code == "provider_transport_fallback"); + } + + private static OpenAIResponsesProvider Provider( + CountingHandler handler, + OpenAIWebSocketConnectionFactory factory, + string accountId, + ProviderResponseObserver? responseObserver = null) + { + var options = OpenAICodexResponses.CreateOptions(new HttpClient(handler), Token(accountId)); + options.WebSocketConnectionFactory = factory; + options.WebSocketIdleTimeoutMilliseconds = 1_000; + options.ResponseObserver = responseObserver; + return new OpenAIResponsesProvider(options); + } + + private static ModelRequest Request( + IReadOnlyList messages, + ModelTransport transport, + ModelCacheRetention retention, + string? sessionId) => + new( + "model", + string.Empty, + messages, + Array.Empty(), + new ModelParameters + { + Transport = transport, + CacheRetention = retention, + WebSocketConnectTimeoutMilliseconds = 1_000, + }, + sessionId, + Guid.NewGuid().ToString("N"), + 1); + + private static AgentMessage Assistant(ModelResponse response, string model) => + new( + AgentRole.Assistant, + response.Content, + DateTimeOffset.UnixEpoch, + model: model, + stopReason: response.StopReason, + usage: response.Usage, + provider: response.Provider, + api: response.Api, + responseModel: response.ResponseModel, + responseId: response.ResponseId, + rawStopReason: response.RawStopReason, + endTurn: response.EndTurn, + diagnostics: response.Diagnostics); + + private static IReadOnlyList TextResponse(string responseId, string messageId, string text) => + new object[] + { + JsonSerializer.Serialize(new + { + type = "response.output_item.added", + output_index = 0, + item = new { type = "message", id = messageId, role = "assistant", status = "in_progress" }, + }), + JsonSerializer.Serialize(new + { + type = "response.output_text.delta", + output_index = 0, + delta = text, + }), + JsonSerializer.Serialize(new + { + type = "response.output_item.done", + output_index = 0, + item = new + { + type = "message", + id = messageId, + role = "assistant", + status = "completed", + content = new[] { new { type = "output_text", text } }, + }, + }), + Completed(responseId), + }; + + private static string Completed(string responseId) => JsonSerializer.Serialize(new + { + type = "response.completed", + response = new + { + id = responseId, + model = "model", + status = "completed", + usage = new { input_tokens = 1, output_tokens = 1, total_tokens = 2 }, + }, + }); + + private static async Task> CollectAsync( + IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var streamEvent in stream) + { + events.Add(streamEvent); + } + + return events; + } + + private static string Token(string accountId) + { + var payload = JsonSerializer.Serialize(new Dictionary + { + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_account_id"] = accountId, + }, + }); + return Base64Url("{\"alg\":\"none\"}") + "." + Base64Url(payload) + ".signature"; + } + + private static string Base64Url(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private sealed class QueueFactory + { + private readonly Queue _connections; + + public QueueFactory(params IOpenAIWebSocketConnection[] connections) + { + _connections = new Queue(connections); + } + + public int Calls { get; private set; } + + public List Requests { get; } = new(); + + public async ValueTask ConnectAsync( + OpenAIWebSocketConnectRequest request, + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + Requests.Add(request); + return _connections.Dequeue(); + } + + public static implicit operator OpenAIWebSocketConnectionFactory(QueueFactory factory) => + factory.ConnectAsync; + } + + private sealed class ScriptedConnection : + IOpenAIWebSocketConnection, + IOpenAIWebSocketResponseMetadata + { + private readonly Func> _script; + private readonly Queue _events = new(); + private bool _open = true; + private int _sendCount; + + public ScriptedConnection( + Func> script, + IReadOnlyDictionary? handshakeHeaders = null) + { + _script = script; + HandshakeHeaders = handshakeHeaders + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public bool IsOpen => _open; + + public int HandshakeStatusCode => 101; + + public IReadOnlyDictionary HandshakeHeaders { get; } + + public int DisposeCalls { get; private set; } + + public List SentBodies { get; } = new(); + + public ValueTask SendTextAsync(string text, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + SentBodies.Add(text); + foreach (var item in _script(++_sendCount, text)) + { + _events.Enqueue(item); + } + + return default; + } + + public ValueTask ReceiveTextAsync( + int maximumCharacters, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = _events.Dequeue(); + if (item is Exception exception) + { + return ValueTask.FromException(exception); + } + + var text = Assert.IsType(item); + Assert.True(text.Length <= maximumCharacters); + return new ValueTask(text); + } + + public ValueTask CloseAsync(string reason, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _open = false; + return default; + } + + public void Dispose() + { + DisposeCalls++; + _open = false; + } + } + + private sealed class CountingHandler : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "data: " + Completed("sse-response") + "\n\n", + Encoding.UTF8, + "text/event-stream"), + }); + } + } +} diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAIResponsesProviderTests.cs b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAIResponsesProviderTests.cs new file mode 100644 index 0000000..751cabc --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenAIResponsesProviderTests.cs @@ -0,0 +1,479 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; +using Xunit; + +namespace OpenGameAgent.Providers.OpenAI.Tests; + +public sealed class OpenAIResponsesProviderTests +{ + [Fact] + public async Task StreamsReasoningTextToolCallsIdentityAndDetailedUsage() + { + const string stream = """ + data: {"type":"response.created","response":{"id":"resp_1","model":"served-model"}} + + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[]}} + + data: {"type":"response.reasoning_summary_text.delta","output_index":0,"delta":"plan"} + + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[{"text":"plan"}],"encrypted_content":"opaque"}} + + data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}} + + data: {"type":"response.output_text.delta","output_index":1,"delta":"hello"} + + data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","id":"msg_1","role":"assistant","status":"completed","phase":"final_answer","content":[{"type":"output_text","text":"hello","annotations":[]}]}} + + data: {"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"move","arguments":""}} + + data: {"type":"response.function_call_arguments.delta","output_index":2,"delta":"{\"x\":1}"} + + data: {"type":"response.function_call_arguments.done","output_index":2,"arguments":"{\"x\":1}"} + + data: {"type":"response.output_item.done","output_index":2,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"move","arguments":"{\"x\":1}"}} + + data: {"type":"response.completed","response":{"id":"resp_1","model":"served-model","status":"completed","output":[{"type":"reasoning","id":"rs_1","summary":[{"text":"plan"}],"encrypted_content":"opaque"}],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":3},"output_tokens_details":{"reasoning_tokens":1}}}} + + data: [DONE] + + """; + var provider = Create(new StubHandler(_ => Response(stream))); + + var events = await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("openai", response.Provider); + Assert.Equal("openai-responses", response.Api); + Assert.Equal("resp_1", response.ResponseId); + Assert.Equal("served-model", response.ResponseModel); + Assert.Equal("completed", response.RawStopReason); + Assert.True(response.EndTurn); + var reasoning = Assert.IsType(response.Content[0]); + Assert.Equal("plan", reasoning.Text); + Assert.Contains("opaque", reasoning.Signature, StringComparison.Ordinal); + var text = Assert.IsType(response.Content[1]); + Assert.Equal("hello", text.Text); + Assert.Equal(AgentTextPhase.FinalAnswer, text.Phase); + var call = Assert.IsType(response.Content[2]); + Assert.Equal("call_1|fc_1", call.Id); + Assert.Equal("{\"x\":1}", call.ArgumentsJson); + Assert.Equal(5, response.Usage.InputTokens); + Assert.Equal(2, response.Usage.CacheReadTokens); + Assert.Equal(3, response.Usage.CacheWriteTokens); + Assert.Equal(1, response.Usage.ReasoningTokens); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ReasoningDelta && item.Delta == "plan"); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.TextDelta && item.Delta == "hello"); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded && item.Content == "plan"); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.TextEnded && item.Content == "hello"); + Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ToolCallDelta && item.Delta == "{\"x\":1}"); + var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + Assert.Equal(call.Id, toolEnded.ToolCallId); + Assert.Equal(call.Name, toolEnded.ToolName); + Assert.Equal(call.ArgumentsJson, toolEnded.ToolCall!.ArgumentsJson); + } + + [Fact] + public async Task SerializesNativeInputCacheStrictToolsAndDeferredToolLoading() + { + var handler = new StubHandler(_ => Response(""" + data: {"type":"response.completed","response":{"id":"resp_1","model":"model","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}} + + """)); + var options = Options(new HttpClient(handler)); + options.SupportsStrictTools = true; + options.SupportsAdditionalTools = true; + var provider = new OpenAIResponsesProvider(options); + var initial = new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}"); + var loaded = new ToolDefinition("move", "Move", "{\"type\":\"object\"}"); + var call = new ToolCallContent("call_1|fc_1", "inspect", "{}"); + var request = new ModelRequest( + "model", + "rules", + new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png"), + }, + DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse, + provider: "openai", + api: "openai-responses"), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] { new TextContent("clear") }, addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch), + }, + new[] { initial, loaded }, + new ModelParameters + { + MaxOutputTokens = 1, + ReasoningLevel = "high", + CacheRetention = ModelCacheRetention.Long, + }, + "session-1", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.Equal(16, root.GetProperty("max_output_tokens").GetInt32()); + Assert.Equal("24h", root.GetProperty("prompt_cache_retention").GetString()); + Assert.Equal("session-1", root.GetProperty("prompt_cache_key").GetString()); + Assert.Single(root.GetProperty("tools").EnumerateArray()); + Assert.False(root.GetProperty("tools")[0].GetProperty("strict").GetBoolean()); + Assert.Contains("data:image/png;base64,aW1hZ2U=", handler.RequestBody, StringComparison.Ordinal); + var additional = Assert.Single(root.GetProperty("input").EnumerateArray(), item => + item.TryGetProperty("type", out var type) && type.GetString() == "additional_tools"); + Assert.Equal("move", additional.GetProperty("tools")[0].GetProperty("name").GetString()); + } + + [Theory] + [InlineData(true, true, "additional_tools")] + [InlineData(false, true, "tool_search_output")] + [InlineData(false, false, "top_level")] + public async Task DeferredToolsSelectNativeThenSearchThenTopLevelFallback( + bool supportsAdditionalTools, + bool supportsToolSearch, + string expectedMode) + { + var handler = new StubHandler(_ => EmptyCompletedResponse()); + var options = Options(new HttpClient(handler)); + options.SupportsAdditionalTools = supportsAdditionalTools; + options.SupportsToolSearch = supportsToolSearch; + var provider = new OpenAIResponsesProvider(options); + + await CollectAsync(provider.StreamAsync( + DeferredRequest(includeLoadedCall: false), + TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + var input = root.GetProperty("input").EnumerateArray().ToArray(); + var topLevelNames = root.GetProperty("tools").EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString()) + .ToArray(); + var additional = input.Where(item => + item.TryGetProperty("type", out var type) && type.GetString() == "additional_tools").ToArray(); + var searches = input.Where(item => + item.TryGetProperty("type", out var type) && type.GetString() == "tool_search_output").ToArray(); + + if (expectedMode == "additional_tools") + { + Assert.Equal(new[] { "inspect" }, topLevelNames); + Assert.Equal("move", Assert.Single(additional).GetProperty("tools")[0].GetProperty("name").GetString()); + Assert.Empty(searches); + } + else if (expectedMode == "tool_search_output") + { + Assert.Equal(new[] { "inspect" }, topLevelNames); + Assert.Empty(additional); + var search = Assert.Single(searches); + Assert.Equal("move", search.GetProperty("tools")[0].GetProperty("name").GetString()); + var searchCall = Assert.Single(input, item => + item.TryGetProperty("type", out var type) && type.GetString() == "tool_search_call"); + Assert.Equal(searchCall.GetProperty("call_id").GetString(), search.GetProperty("call_id").GetString()); + } + else + { + Assert.Equal(new[] { "inspect", "move" }, topLevelNames); + Assert.Empty(additional); + Assert.Empty(searches); + } + } + + [Fact] + public async Task DeferredToolMarkerPrecedesReplayAndIsNotDuplicated() + { + var handler = new StubHandler(_ => EmptyCompletedResponse()); + var options = Options(new HttpClient(handler)); + options.SupportsAdditionalTools = true; + options.SupportsToolSearch = true; + var provider = new OpenAIResponsesProvider(options); + + await CollectAsync(provider.StreamAsync( + DeferredRequest(includeLoadedCall: true), + TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var input = document.RootElement.GetProperty("input").EnumerateArray().ToArray(); + var markerIndexes = input.Select((item, index) => (item, index)) + .Where(value => value.item.TryGetProperty("type", out var type) && type.GetString() == "additional_tools") + .Select(value => value.index) + .ToArray(); + var loadedCallIndex = Array.FindIndex(input, item => + item.TryGetProperty("type", out var type) + && type.GetString() == "function_call" + && item.GetProperty("name").GetString() == "move"); + + Assert.Single(markerIndexes); + Assert.True(markerIndexes[0] < loadedCallIndex); + Assert.Equal(new[] { "inspect" }, document.RootElement.GetProperty("tools").EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString()) + .ToArray()); + } + + [Fact] + public async Task StreamsGrammarCustomToolAsJsonArguments() + { + const string stream = """ + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"ctc_1","call_id":"call_1","name":"choose","input":""}} + + data: {"type":"response.custom_tool_call_input.delta","output_index":0,"delta":"ab"} + + data: {"type":"response.custom_tool_call_input.done","output_index":0,"input":"ab"} + + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"custom_tool_call","id":"ctc_1","call_id":"call_1","name":"choose","input":"ab"}} + + data: {"type":"response.completed","response":{"id":"resp_1","model":"model","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}} + + """; + var options = Options(new HttpClient(new StubHandler(_ => Response(stream)))); + options.SupportsGrammarTools = true; + var provider = new OpenAIResponsesProvider(options); + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + new[] + { + new ToolDefinition( + "choose", + "Choose", + "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}", + ToolConstrainedSampling.Grammar(openAiRegex: "[a-z]+")), + }, + new ModelParameters(), + null, + "run", + 1); + + var result = (await CollectAsync(provider.StreamAsync( + request, + TestContext.Current.CancellationToken))).Last().Response!; + + Assert.Equal(ModelStopReason.ToolUse, result.StopReason); + Assert.Equal("{\"value\":\"ab\"}", Assert.IsType(Assert.Single(result.Content)).ArgumentsJson); + } + + [Fact] + public async Task RejectsStreamWithoutTerminalResponse() + { + var provider = Create(new StubHandler(_ => Response(""" + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}} + + data: {"type":"response.output_text.delta","output_index":0,"delta":"partial"} + + """))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + Assert.Contains("terminal", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ResponseObserverReceivesOnlySanitizedMetadataAndFailureIsIsolated() + { + ProviderResponseObservation? observed = null; + var response = EmptyCompletedResponse(); + response.Headers.TryAddWithoutValidation("x-request-id", "request-1\r\nforged"); + response.Headers.TryAddWithoutValidation("set-cookie", "credential=secret"); + var options = Options(new HttpClient(new StubHandler(_ => response))); + options.ResponseObserver = (observation, _) => + { + observed = observation; + throw new InvalidOperationException("observer failure"); + }; + + var events = await CollectAsync(new OpenAIResponsesProvider(options).StreamAsync( + Request(), + TestContext.Current.CancellationToken)); + + Assert.True(events[^1].IsTerminal); + Assert.NotNull(observed); + Assert.Equal(200, observed.StatusCode); + Assert.Equal("request-1 forged", observed.Metadata["x-request-id"]); + Assert.Single(observed.Metadata); + } + + [Fact] + public async Task FailureRejectsServerRetryDelayAboveSafetyLimit() + { + var retryAt = DateTimeOffset.UtcNow.AddMinutes(3); + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("retry", Encoding.UTF8, "text/plain"), + }; + response.Headers.TryAddWithoutValidation("x-should-retry", "true"); + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(retryAt); + var provider = Create(new StubHandler(_ => response)); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + + Assert.False(exception.IsTransient); + Assert.Equal(400, exception.StatusCode); + Assert.InRange(exception.RetryAfter!.Value, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(3.1)); + } + + [Fact] + public async Task NullHeaderSuppressesOptionalSessionDefaultAndTransportHeadersAreRejected() + { + var handler = new StubHandler(_ => EmptyCompletedResponse()); + var options = Options(new HttpClient(handler)); + options.Headers["session_id"] = null; + var provider = new OpenAIResponsesProvider(options); + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + Array.Empty(), + new ModelParameters { CacheRetention = ModelCacheRetention.Short }, + "session-one", + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + Assert.DoesNotContain("session_id", handler.RequestHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("session-one", handler.RequestHeaders["x-client-request-id"]); + + var malicious = Options(new HttpClient(new StubHandler(_ => EmptyCompletedResponse()))); + malicious.Headers["Host"] = "attacker.example"; + Assert.Throws(() => new OpenAIResponsesProvider(malicious)); + + var credentialHeader = Options(new HttpClient(new StubHandler(_ => EmptyCompletedResponse()))); + credentialHeader.AuthenticationStyle = OpenAIAuthenticationStyle.ApiKeyHeader; + credentialHeader.ApiKeyHeaderName = "Host"; + credentialHeader.ApiKey = "secret"; + Assert.Throws(() => new OpenAIResponsesProvider(credentialHeader)); + + credentialHeader.ApiKeyHeaderName = null!; + Assert.Throws(() => new OpenAIResponsesProvider(credentialHeader)); + } + + private static OpenAIResponsesProvider Create(HttpMessageHandler handler) => + new(Options(new HttpClient(handler))); + + private static HttpResponseMessage EmptyCompletedResponse() => Response(""" + data: {"type":"response.completed","response":{"id":"resp_1","model":"model","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}} + + """); + + private static ModelRequest DeferredRequest(bool includeLoadedCall) + { + var inspect = new ToolCallContent("call_inspect|fc_inspect", "inspect", "{}"); + var move = new ToolCallContent("call_move|fc_move", "move", "{}"); + var messages = new List + { + AgentMessage.User("start", DateTimeOffset.UnixEpoch), + new( + AgentRole.Assistant, + new AgentContent[] { inspect }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse, + provider: "openai", + api: "openai-responses"), + AgentMessage.ToolResult( + inspect, + new ToolResult(new AgentContent[] { new TextContent("loaded") }, addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch), + }; + if (includeLoadedCall) + { + messages.Add(new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { move }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse, + provider: "openai", + api: "openai-responses")); + messages.Add(AgentMessage.ToolResult( + move, + new ToolResult(new AgentContent[] { new TextContent("done") }, addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch)); + } + + messages.Add(AgentMessage.User("continue", DateTimeOffset.UnixEpoch)); + return new ModelRequest( + "model", + "rules", + messages, + new[] + { + new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}"), + new ToolDefinition("move", "Move", "{\"type\":\"object\"}"), + }, + new ModelParameters(), + "session", + "run", + 1); + } + + private static OpenAIResponsesProviderOptions Options(HttpClient client) => + new(client, new Uri("https://api.example.test/v1/responses")); + + private static ModelRequest Request() => + new("model", "rules", Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); + + private static HttpResponseMessage Response(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }; + + private static async Task> CollectAsync(IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var item in stream.WithCancellation(TestContext.Current.CancellationToken)) + { + events.Add(item); + } + + return events; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _response; + + public StubHandler(Func response) + { + _response = response; + } + + public string? RequestBody { get; private set; } + + public IReadOnlyDictionary RequestHeaders { get; private set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + RequestHeaders = request.Headers.ToDictionary( + header => header.Key, + header => string.Join(",", header.Value), + StringComparer.OrdinalIgnoreCase); + return _response(request); + } + } +} diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenGameAgent.Providers.OpenAI.Tests.csproj b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenGameAgent.Providers.OpenAI.Tests.csproj new file mode 100644 index 0000000..f142993 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/OpenGameAgent.Providers.OpenAI.Tests.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.OpenAI.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json new file mode 100644 index 0000000..9941aa6 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json @@ -0,0 +1,224 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.openai": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providertransport": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/OpenGameAgent.Providers.OpenAICompatible.Tests.csproj b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/OpenGameAgent.Providers.OpenAICompatible.Tests.csproj index d6805a7..73b0ae7 100644 --- a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/OpenGameAgent.Providers.OpenAICompatible.Tests.csproj +++ b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/OpenGameAgent.Providers.OpenAICompatible.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs index fa1d65a..fa0bb59 100644 --- a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs +++ b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using OpenGameAgent.Kernel; +using OpenGameAgent.ProviderTransport; using Xunit; namespace OpenGameAgent.Providers.OpenAICompatible.Tests; @@ -52,11 +53,24 @@ public async Task StreamsReasoningTextToolArgumentsAndUsage() var toolDelta = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallDelta && item.Delta == "{\"speed\":"); Assert.Equal("call-1", toolDelta.ToolCallId); Assert.Equal("move", toolDelta.ToolName); - Assert.Contains(events, item => item.Kind == ModelStreamEventKind.ReasoningEnded); - Assert.Contains(events, item => item.Kind == ModelStreamEventKind.TextEnded); + var reasoningEnded = Assert.Single(events, item => + item.Kind == ModelStreamEventKind.ReasoningEnded && item.Content == "think"); + var textEnded = Assert.Single(events, item => + item.Kind == ModelStreamEventKind.TextEnded && item.Content == "hello"); + Assert.Equal(0, reasoningEnded.ContentIndex); + Assert.Equal(1, textEnded.ContentIndex); var toolEnded = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallEnded); + var toolStarted = Assert.Single(events, item => item.Kind == ModelStreamEventKind.ToolCallStarted); Assert.Equal("call-1", toolEnded.ToolCallId); Assert.Equal("move", toolEnded.ToolName); + Assert.Equal(2, toolStarted.ContentIndex); + Assert.Equal(toolStarted.ContentIndex, toolDelta.ContentIndex); + Assert.Equal(toolStarted.ContentIndex, toolEnded.ContentIndex); + Assert.Equal(call.Id, toolEnded.ToolCall!.Id); + Assert.Equal(call.Name, toolEnded.ToolCall.Name); + Assert.Equal(call.ArgumentsJson, toolEnded.ToolCall.ArgumentsJson); + var partialCall = Assert.IsType(toolEnded.Partial!.Content[toolEnded.ContentIndex]); + Assert.Equal(call.ArgumentsJson, partialCall.ArgumentsJson); } [Fact] @@ -121,7 +135,9 @@ public async Task SendsToolsExtensionsAndRotatingAuthorizationWithoutLeakingItIn new AgentContent[] { new ReasoningContent("private-plan"), new TextContent("public-answer") }, DateTimeOffset.UnixEpoch, model: "model", - stopReason: ModelStopReason.Stop), + stopReason: ModelStopReason.Stop, + provider: "openai-compatible", + api: "openai-completions"), }, new[] { new ToolDefinition("move", "Move", "{\"type\":\"object\"}") }, parameters, @@ -278,7 +294,9 @@ public async Task ReplaysSignedReasoningForToolContinuationButKeepsUnsignedReaso }, DateTimeOffset.UnixEpoch, model: "model", - stopReason: ModelStopReason.Stop), + stopReason: ModelStopReason.Stop, + provider: "openai-compatible", + api: "openai-completions"), }, Array.Empty(), new ModelParameters(), @@ -335,6 +353,95 @@ public async Task HttpFailureHonorsRetryDirectivesAndBoundedServerDelay() Assert.Equal(400, exception.StatusCode); } + [Fact] + public async Task ResponseObserverIsSanitizedBoundedAndCannotBreakSuccess() + { + ProviderResponseObservation? observed = null; + var handler = new StubHandler(_ => + { + var response = Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream"); + response.Headers.TryAddWithoutValidation("x-request-id", "request-1\r\nforged"); + response.Headers.TryAddWithoutValidation("authorization", "Bearer secret"); + return response; + }); + var options = Options(new HttpClient(handler)); + options.ResponseObserver = (observation, _) => + { + observed = observation; + throw new InvalidOperationException("observer failure"); + }; + + var events = await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + Request(), + TestContext.Current.CancellationToken)); + + Assert.True(events[^1].IsTerminal); + Assert.NotNull(observed); + Assert.Equal("request-1 forged", observed.Metadata["x-request-id"]); + Assert.Single(observed.Metadata); + } + + [Fact] + public async Task HttpFailureRejectsRetryAfterDateAboveSafetyLimit() + { + var retryAt = DateTimeOffset.UtcNow.AddMinutes(3); + var handler = new StubHandler(_ => + { + var response = Response(HttpStatusCode.TooManyRequests, "retry", "text/plain"); + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(retryAt); + return response; + }); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(Create(handler).StreamAsync(Request(), TestContext.Current.CancellationToken))); + + Assert.False(exception.IsTransient); + Assert.InRange(exception.RetryAfter!.Value, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(3.1)); + } + + [Fact] + public async Task NullHeaderSuppressesSessionDefaultAndTransportHeadersAreRejected() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var options = Options(new HttpClient(handler)); + options.Protocol.SendSessionAffinityHeaders = true; + options.Protocol.SessionAffinityFormat = OpenAICompatibleSessionAffinityFormat.OpenRouter; + options.Headers["x-session-id"] = null; + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + Array.Empty(), + new ModelParameters(), + "session-one", + "run", + 1); + + await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken)); + + Assert.DoesNotContain("x-session-id", handler.Headers.Keys, StringComparer.OrdinalIgnoreCase); + + var malicious = Options(new HttpClient(new StubHandler(_ => throw new InvalidOperationException()))); + malicious.Headers["Content-Length"] = "1"; + Assert.Throws(() => new OpenAICompatibleProvider(malicious)); + + var credentialHeader = Options(new HttpClient(new StubHandler(_ => throw new InvalidOperationException()))); + credentialHeader.ApiKey = "secret"; + credentialHeader.ApiKeyHeader = "Content-Length"; + Assert.Throws(() => new OpenAICompatibleProvider(credentialHeader)); + + credentialHeader.ApiKeyHeader = null!; + Assert.Throws(() => new OpenAICompatibleProvider(credentialHeader)); + } + [Fact] public async Task TruncatedStreamWithoutFinishReasonFails() { @@ -483,7 +590,7 @@ public async Task LengthTruncatedToolArgumentsStillProduceAClosableToolCall() var response = events.Last().Response!; Assert.Equal(ModelStopReason.Length, response.StopReason); - Assert.Equal("{}", Assert.IsType(Assert.Single(response.Content)).ArgumentsJson); + Assert.Equal("{\"x\":null}", Assert.IsType(Assert.Single(response.Content)).ArgumentsJson); } [Fact] @@ -868,10 +975,192 @@ public async Task InvalidationDuringCredentialRefreshCannotReinstallTheRevokedTo Assert.True(source.ForceRefreshValues.Last()); } - private static OpenAICompatibleProvider Create(HttpMessageHandler handler) => - new(new OpenAICompatibleProviderOptions( + [Fact] + public async Task PreservesResponseIdentityRawStopReasonAndDetailedUsage() + { + const string stream = """ + data: {"id":"response-1","model":"served-model","choices":[{"delta":{"content":"ok"},"finish_reason":null}]} + + data: {"id":"response-1","model":"served-model","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":2,"cache_write_tokens":3},"completion_tokens_details":{"reasoning_tokens":1}}} + + data: {"id":"response-1","model":"served-model","choices":[{"delta":{},"finish_reason":"end"}]} + + data: [DONE] + + """; + var options = new OpenAICompatibleProviderOptions( + new HttpClient(new StubHandler(_ => Response(HttpStatusCode.OK, stream, "text/event-stream"))), + new Uri("https://example.test/v1/chat/completions")) + { + ProviderId = "provider-a", + ApiId = "chat-api", + }; + + var events = await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + Request(), + TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + Assert.Equal("provider-a", response.Provider); + Assert.Equal("chat-api", response.Api); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("served-model", response.ResponseModel); + Assert.Equal("end", response.RawStopReason); + Assert.Equal(5, response.Usage.InputTokens); + Assert.Equal(2, response.Usage.CacheReadTokens); + Assert.Equal(3, response.Usage.CacheWriteTokens); + Assert.Equal(1, response.Usage.ReasoningTokens); + } + + [Fact] + public async Task ProtocolOptionsControlRequestShapeAndInferMissingFinishReason() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var options = new OpenAICompatibleProviderOptions( new HttpClient(handler), - new Uri("https://example.test/v1/chat/completions"))); + new Uri("https://example.test/v1/chat/completions")); + options.Protocol.SupportsDeveloperRole = true; + options.Protocol.SupportsStore = true; + options.Protocol.SupportsFinishReason = false; + options.Protocol.MaxTokensField = OpenAICompatibleMaxTokensField.MaxCompletionTokens; + options.Protocol.RequiresToolResultName = true; + options.Protocol.RequiresAssistantAfterToolResult = true; + options.Protocol.ThinkingFormat = OpenAICompatibleThinkingFormat.DeepSeek; + options.Protocol.SendSessionAffinityHeaders = true; + options.Protocol.SessionAffinityFormat = OpenAICompatibleSessionAffinityFormat.OpenRouter; + var call = new ToolCallContent("call-1", "inspect", "{}"); + var request = new ModelRequest( + "model", + "rules", + new AgentMessage[] + { + AgentMessage.User("inspect"), + new( + AgentRole.Assistant, + new AgentContent[] { call }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult( + call, + new ToolResult(new AgentContent[] { new TextContent("clear") }), + DateTimeOffset.UnixEpoch), + AgentMessage.User("continue"), + }, + new[] { new ToolDefinition("inspect", "Inspect", "{\"type\":\"object\"}") }, + new ModelParameters + { + Temperature = 0.2, + MaxOutputTokens = 100, + ReasoningLevel = "high", + CacheRetention = ModelCacheRetention.Long, + SamplingParametersJson = "{\"temperature\":0.9}", + }, + "session-1", + "run", + 1); + + var response = (await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken))).Last().Response!; + + Assert.Equal(ModelStopReason.Stop, response.StopReason); + Assert.Equal("session-1", handler.Headers["x-session-id"]); + using var document = JsonDocument.Parse(handler.RequestBody!); + var root = document.RootElement; + Assert.True(root.GetProperty("store").ValueKind == JsonValueKind.False); + Assert.Equal(100, root.GetProperty("max_completion_tokens").GetInt32()); + Assert.Equal(0.9, root.GetProperty("temperature").GetDouble()); + Assert.Equal("24h", root.GetProperty("prompt_cache_retention").GetString()); + Assert.Equal("developer", root.GetProperty("messages")[0].GetProperty("role").GetString()); + Assert.Equal("inspect", root.GetProperty("messages")[3].GetProperty("name").GetString()); + Assert.Equal("assistant", root.GetProperty("messages")[4].GetProperty("role").GetString()); + Assert.Equal("enabled", root.GetProperty("thinking").GetProperty("type").GetString()); + } + + [Fact] + public async Task SerializesStrictAndGrammarConstrainedTools() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var options = new OpenAICompatibleProviderOptions( + new HttpClient(handler), + new Uri("https://example.test/v1/chat/completions")); + options.Protocol.SupportsGrammarTools = true; + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + new[] + { + new ToolDefinition( + "choose", + "Choose", + "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}", + ToolConstrainedSampling.Grammar(openAiRegex: "[a-z]+")), + new ToolDefinition( + "move", + "Move", + "{\"type\":\"object\"}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)), + }, + new ModelParameters(), + null, + "run", + 1); + + await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var tools = document.RootElement.GetProperty("tools"); + Assert.Equal("custom", tools[0].GetProperty("type").GetString()); + Assert.Equal("regex", tools[0].GetProperty("custom").GetProperty("format") + .GetProperty("grammar").GetProperty("syntax").GetString()); + Assert.True(tools[1].GetProperty("function").GetProperty("strict").GetBoolean()); + } + + [Fact] + public async Task RejectsRequiredStrictSamplingWhenEndpointCannotHonorIt() + { + var options = new OpenAICompatibleProviderOptions( + new HttpClient(new StubHandler(_ => throw new InvalidOperationException("transport must not run"))), + new Uri("https://example.test/v1/chat/completions")); + options.Protocol.SupportsStrictMode = false; + var request = new ModelRequest( + "model", + "rules", + Array.Empty(), + new[] + { + new ToolDefinition( + "move", + "Move", + "{\"type\":\"object\"}", + ToolConstrainedSampling.JsonSchema(ToolSchemaStrictness.Require)), + }, + new ModelParameters(), + null, + "run", + 1); + + await Assert.ThrowsAsync(async () => + await CollectAsync(new OpenAICompatibleProvider(options).StreamAsync( + request, + TestContext.Current.CancellationToken))); + } + + private static OpenAICompatibleProvider Create(HttpMessageHandler handler) => + new(Options(new HttpClient(handler))); + + private static OpenAICompatibleProviderOptions Options(HttpClient client) => + new(client, new Uri("https://example.test/v1/chat/completions")); private static ModelRequest Request() => new("model", "rules", Array.Empty(), Array.Empty(), new ModelParameters(), null, "run", 1); @@ -907,6 +1196,9 @@ public StubHandler(Func response) public string? Authorization { get; private set; } + public IReadOnlyDictionary Headers { get; private set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) @@ -917,6 +1209,10 @@ protected override async Task SendAsync( Authorization = request.Headers.TryGetValues("Authorization", out var values) ? Assert.Single(values) : null; + Headers = request.Headers.ToDictionary( + header => header.Key, + header => string.Join(",", header.Value), + StringComparer.OrdinalIgnoreCase); return _response(request); } } diff --git a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json index e5f037b..169c209 100644 --- a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json @@ -212,8 +212,12 @@ "type": "Project", "dependencies": { "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } + }, + "opengameagent.providertransport": { + "type": "Project" } } } diff --git a/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenGameAgent.Providers.OpenRouter.Tests.csproj b/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenGameAgent.Providers.OpenRouter.Tests.csproj new file mode 100644 index 0000000..0d3fb33 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenGameAgent.Providers.OpenRouter.Tests.csproj @@ -0,0 +1,14 @@ + + + net8.0 + false + + + + + + + + + + diff --git a/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenRouterImageProviderTests.cs b/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenRouterImageProviderTests.cs new file mode 100644 index 0000000..8836b4d --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenRouter.Tests/OpenRouterImageProviderTests.cs @@ -0,0 +1,548 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Kernel; +using OpenGameAgent.Media; +using OpenGameAgent.Models; +using Xunit; + +namespace OpenGameAgent.Providers.OpenRouter.Tests; + +public sealed class OpenRouterImageProviderTests +{ + [Fact] + public async Task RefreshDiscoversExecutableImageModelsThroughUnifiedAuthentication() + { + var handler = new RecordingHandler((request, _) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://openrouter.test/api/v1/images/models", request.RequestUri!.AbsoluteUri); + Assert.Equal("Bearer secret", request.Headers.Authorization!.ToString()); + return Json(HttpStatusCode.OK, """ + { + "data": [ + { + "id": "vendor/image-model", + "name": "Image Model", + "description": "Creates images", + "architecture": { + "input_modalities": ["text", "image"], + "output_modalities": ["image"] + }, + "supported_parameters": { "quality": { "type": "enum" } }, + "supports_streaming": true + }, + { + "id": "vendor/text-model", + "architecture": { + "input_modalities": ["text"], + "output_modalities": ["text"] + } + } + ] + } + """); + }); + using var client = new HttpClient(handler); + var options = Options(client); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration(options, Authentication())); + + var refreshed = await registry.RefreshAsync( + OpenRouterImageProvider.ProviderId, + TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelRefreshStatus.Updated, refreshed.Status); + var model = Assert.Single(registry.GetModels(OpenRouterImageProvider.ProviderId)); + Assert.Equal("vendor/image-model", model.ModelId); + Assert.Equal(OpenRouterImageProvider.ApiId, model.Api); + Assert.True(model.InputCapabilities.HasFlag(GameModelInputCapabilities.Text)); + Assert.True(model.InputCapabilities.HasFlag(GameModelInputCapabilities.Image)); + Assert.Equal(GameModelOutputCapabilities.Image, model.OutputCapabilities); + Assert.Equal("true", model.Metadata["supportsStreaming"]); + } + + [Fact] + public async Task BufferedGenerationMapsParametersReferencesAuthenticationAndOutputs() + { + JsonDocument? captured = null; + var handler = new RecordingHandler(async (request, cancellationToken) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://openrouter.test/api/v1/images", request.RequestUri!.AbsoluteUri); + Assert.Equal("Bearer secret", request.Headers.Authorization!.ToString()); + Assert.Equal("game", request.Headers.GetValues("X-Game").Single()); + captured = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + return Json(HttpStatusCode.OK, """ + { + "created": 123, + "data": [ + { "b64_json": "aW1hZ2U=", "media_type": "image/webp" } + ], + "usage": { "prompt_tokens": 5, "completion_tokens": 7, "cost": 0.01 } + } + """, requestId: "request-1"); + }); + using var client = new HttpClient(handler); + var options = Options(client); + options.Headers["X-Game"] = "game"; + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + options, + Authentication(), + new[] { ImageModel() })); + var reference = "data:image/png;base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes("reference")); + var request = new GameMediaGenerationRequest( + "request", + GameMediaKind.Image, + "{\"privateContext\":\"must-not-leave-the-game\"}", + "{\"n\":1,\"aspect_ratio\":\"16:9\",\"quality\":\"high\"}", + "A mountain village", + new[] { new ResourceContent(reference, "image/png") }); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + request, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Completed, generated.Status); + var result = Assert.IsType(generated.Result); + var output = Assert.Single(result.Outputs); + Assert.Equal("image/webp", output.MediaType); + Assert.Equal("data:image/webp;base64,aW1hZ2U=", output.Uri); + Assert.Equal("request-1", result.ProviderRequestId); + Assert.Contains("\"cost\":0.01", result.MetadataJson, StringComparison.Ordinal); + var root = captured!.RootElement; + Assert.Equal("vendor/image-model", root.GetProperty("model").GetString()); + Assert.Equal("A mountain village", root.GetProperty("prompt").GetString()); + Assert.Equal("16:9", root.GetProperty("aspect_ratio").GetString()); + Assert.Equal(reference, root.GetProperty("input_references")[0].GetProperty("image_url").GetProperty("url").GetString()); + Assert.DoesNotContain("must-not-leave-the-game", root.GetRawText(), StringComparison.Ordinal); + captured.Dispose(); + } + + [Fact] + public async Task StreamingGenerationReportsPartialProgressAndReturnsCompletedImage() + { + var body = string.Join("\n\n", new[] + { + "data: {\"type\":\"image_generation.partial_image\",\"partial_image_index\":2,\"b64_json\":\"cGFydGlhbA==\"}", + "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"ZmluYWw=\",\"media_type\":\"image/png\",\"created\":456,\"usage\":{\"cost\":0.02}}", + "data: [DONE]", + string.Empty, + }); + var handler = new RecordingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + var progress = new List(); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest( + "stream", + GameMediaKind.Image, + "{}", + "{\"stream\":true}", + "A castle"), + (update, _) => + { + progress.Add(update); + return ValueTask.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Completed, generated.Status); + Assert.Equal("data:image/png;base64,ZmluYWw=", Assert.Single(generated.Result!.Outputs).Uri); + var update = Assert.Single(progress); + Assert.Equal("partial_image", update.Stage); + Assert.Contains("\"index\":2", update.DetailsJson, StringComparison.Ordinal); + Assert.Equal("data:image/png;base64,cGFydGlhbA==", update.Preview!.Uri); + Assert.Contains("\"created\":456", generated.Result.MetadataJson, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthenticationHeaderTombstonesSuppressConfiguredDefaultsAndCredential() + { + var handler = new RecordingHandler((request, _) => + { + Assert.False(request.Headers.Contains("Authorization")); + Assert.False(request.Headers.Contains("X-Game")); + return request.Method == HttpMethod.Get + ? Json(HttpStatusCode.OK, """ + {"data":[{"id":"vendor/image-model","architecture":{"input_modalities":["text"],"output_modalities":["image"]}}]} + """) + : Json(HttpStatusCode.OK, "{\"data\":[{\"b64_json\":\"aW1hZ2U=\"}]}"); + }); + using var client = new HttpClient(handler); + var options = Options(client); + options.Headers["Authorization"] = "Bearer configured"; + options.Headers["X-Game"] = "configured"; + var authentication = new ResolutionAuthentication(new GameProviderAuthResolution( + new GameCredential(GameCredentialKind.ApiKey, "secret"), + "test", + headers: new Dictionary + { + ["Authorization"] = null, + ["X-Game"] = null, + })); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + options, + authentication, + new[] { ImageModel() })); + + var refreshed = await registry.RefreshAsync( + OpenRouterImageProvider.ProviderId, + TestContext.Current.CancellationToken); + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest("headers", GameMediaKind.Image, "{}", prompt: "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelRefreshStatus.Updated, refreshed.Status); + Assert.Equal(GameMediaModelGenerationStatus.Completed, generated.Status); + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task ReservedParametersAndInvalidReferencesFailBeforeNetworkDispatch() + { + var handler = new RecordingHandler((Func) + ((_, _) => throw new InvalidOperationException("must not dispatch"))); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + + var reserved = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest( + "reserved", + GameMediaKind.Image, + "{}", + "{\"model\":\"override\"}", + "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + var invalidReference = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest( + "reference", + GameMediaKind.Image, + "{}", + prompt: "Prompt", + sources: new[] { new ResourceContent("file:///private/image.png", "image/png") }), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, reserved.Status); + Assert.Contains("reserved", reserved.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal(GameMediaModelGenerationStatus.Failed, invalidReference.Status); + Assert.Contains("HTTP(S)", invalidReference.ErrorMessage, StringComparison.Ordinal); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task HttpFailuresAreBoundedAndNeverExposeTheCredential() + { + var handler = new RecordingHandler((_, _) => Json( + HttpStatusCode.TooManyRequests, + "{\"error\":{\"code\":\"rate_limited\",\"message\":\"secret prompt private-reference\"}}")); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest("error", GameMediaKind.Image, "{}", prompt: "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, generated.Status); + Assert.Contains("429", generated.ErrorMessage, StringComparison.Ordinal); + Assert.Contains("rate_limited", generated.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("secret", generated.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("private-reference", generated.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task StreamingErrorsNeverExposeProviderEchoedSecrets() + { + var body = string.Join("\n\n", new[] + { + "data: {\"type\":\"error\",\"error\":{\"code\":\"policy_blocked\",\"message\":\"secret prompt\"}}", + "data: [DONE]", + string.Empty, + }); + var handler = new RecordingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest( + "stream-error", + GameMediaKind.Image, + "{}", + "{\"stream\":true}", + "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, generated.Status); + Assert.Contains("policy_blocked", generated.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("secret", generated.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain("prompt", generated.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TruncatedStreamingResponsesFailInBandAfterPreservingTheirSafetyBoundary() + { + var body = "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"ZmluYWw=\"}\n\n"; + var handler = new RecordingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "text/event-stream"), + }); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest( + "truncated", + GameMediaKind.Image, + "{}", + "{\"stream\":true}", + "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, generated.Status); + Assert.Contains("terminal marker", generated.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task InvalidBase64OutputsFailClosed() + { + var handler = new RecordingHandler((_, _) => Json( + HttpStatusCode.OK, + "{\"data\":[{\"b64_json\":\"not base64\",\"media_type\":\"image/png\"}]}")); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + + var generated = await registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest("invalid", GameMediaKind.Image, "{}", prompt: "Prompt"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameMediaModelGenerationStatus.Failed, generated.Status); + Assert.Contains("base64", generated.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CancellationDuringResponseStreamAcquisitionReturnsPromptlyAndDisposesLateStream() + { + var content = new BlockingStreamContent(); + var handler = new RecordingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content, + }); + using var client = new HttpClient(handler); + using var registry = new GameMediaModelRegistry(); + registry.Register(OpenRouterImageProvider.CreateRegistration( + Options(client), + Authentication(), + new[] { ImageModel() })); + using var cancellation = new CancellationTokenSource(); + + var generation = registry.GenerateAsync( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + new GameMediaGenerationRequest("cancel", GameMediaKind.Image, "{}", prompt: "Prompt"), + cancellationToken: cancellation.Token).AsTask(); + await content.Started.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + cancellation.Cancel(); + + var result = await generation.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + Assert.Equal(GameMediaModelGenerationStatus.Canceled, result.Status); + + var late = new TrackingStream(Encoding.UTF8.GetBytes("{\"data\":[]}")); + content.Release(late); + await late.Disposed.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + } + + private static OpenRouterImageProviderOptions Options(HttpClient client) => new(client) + { + Endpoint = new Uri("https://openrouter.test/api/v1/images"), + }; + + private static IGameProviderAuthentication Authentication() => + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "secret")); + + private static GameModelDescriptor ImageModel() => new( + OpenRouterImageProvider.ProviderId, + "vendor/image-model", + inputCapabilities: GameModelInputCapabilities.Text | GameModelInputCapabilities.Image, + outputCapabilities: GameModelOutputCapabilities.Image, + api: OpenRouterImageProvider.ApiId, + baseUrl: new Uri("https://openrouter.test/api/v1/images")); + + private static HttpResponseMessage Json(HttpStatusCode status, string content, string? requestId = null) + { + var response = new HttpResponseMessage(status) + { + Content = new StringContent(content, Encoding.UTF8, "application/json"), + }; + if (requestId is not null) + { + response.Headers.TryAddWithoutValidation("x-request-id", requestId); + } + + return response; + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func> _send; + + public RecordingHandler(Func send) + { + _send = (request, cancellationToken) => new ValueTask(send(request, cancellationToken)); + } + + public RecordingHandler(Func> send) + { + _send = async (request, cancellationToken) => await send(request, cancellationToken); + } + + public int Calls { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Calls++; + return await _send(request, cancellationToken); + } + } + + private sealed class ResolutionAuthentication : IGameProviderAuthentication + { + private readonly GameProviderAuthResolution _resolution; + + public ResolutionAuthentication(GameProviderAuthResolution resolution) + { + _resolution = resolution; + } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(new GameProviderAuthStatus( + true, + "test", + _resolution.Credential?.Kind)); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_resolution); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Test authentication does not support login."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Test authentication does not support logout."); + } + + private sealed class BlockingStreamContent : HttpContent + { + private readonly TaskCompletionSource _stream = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Started { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Release(Stream stream) => _stream.TrySetResult(stream); + + protected override Task CreateContentReadStreamAsync() + { + Started.TrySetResult(); + return _stream.Task; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } + + private sealed class TrackingStream : MemoryStream + { + public TrackingStream(byte[] bytes) + : base(bytes) + { + } + + public TaskCompletionSource Disposed { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + Disposed.TrySetResult(); + } + } + } +} diff --git a/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json new file mode 100644 index 0000000..cd69df5 --- /dev/null +++ b/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json @@ -0,0 +1,220 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "lbyYtsBxA8Pz8kaf5Xn/Mj1mL9z2nlBWdZhqFaj66nxXBa4JwiTDm4eGcpSMet6du9TOWI6bfha+gQR6+IHawg==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "8AZKk/iiZAzRhNNq8yB8gcNpA+exGMGi3oodWnn0eI7vCwuuuAxnF6ANQC+q7z6FJoOyjiJnnBhS5YMoPPILjg==", + "dependencies": { + "xunit.analyzers": "1.24.0", + "xunit.v3.assert": "[3.0.1]", + "xunit.v3.core": "[3.0.1]" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.8.2", + "contentHash": "QPaJgSfN0APwB6OtJO9jOGETv62d94fsDwLKh+Yu6vAFIP8wfml2CFLzaOlBIkbYFb8kG0s0Bd/VPLQrhCL3lg==", + "dependencies": { + "Microsoft.Testing.Platform": "1.8.2" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.8.2", + "contentHash": "KTr/LYPhgT3IEzElGEEHldNJec0QMTouDhtwDmW+PAPAEzSKJQHtPaNA3pf0IL7ISmoKtuxxHV2v1Cq9xmelzQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.8.2", + "contentHash": "xL7h/wZR6NpzZrFqUMwE6Sa9i7jEPmxPHl11EG9iG1Szh3IHvhJkbmlKG74pm9YLnEysjVYFuA+ExBHxqQp8fg==", + "dependencies": { + "Microsoft.Testing.Platform": "1.8.2" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.24.0", + "contentHash": "kxaoMFFZcQ+mJudaKKlt3gCqV6M6Gjbka0NEs8JFDrxn52O7w5OOnYfSYVfqusk8p7pxrGdjgaQHlGINsNZHAQ==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "3h6AbEcfEsFn9RLjlG5yCTqEjgDHwMXRkNEhpGjAWMM68uHKWeItPbqzWj8P260gDT9NxPFfZRf3huYNnOlLRA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "tL/lcgOHhPw4eb7Vx2WM97JFxW1WM9E0XNTra8UFQWdO9xtR7IXhVIZ6V4uXz5aApZ1FvdrrklImh+xneyrXmw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "Gm6+sQi0LDIqEaWeB2ML0Hy9maYyr3JmptEylXcWpDn3MaUAcxkshZa1Jq60YrqHGG1gVoyCBHYJRd2pEjRs2A==", + "dependencies": { + "Microsoft.Testing.Platform.MSBuild": "1.8.2", + "xunit.v3.extensibility.core": "[3.0.1]", + "xunit.v3.runner.inproc.console": "[3.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "rud2rEHLGkPGeKc+O9OiUVWhLh8enjHYrlMlSgPfGQmUaLzGDoT7IjJcbyyDFJm8jEbWC2iOe6evqyw0ZfZWcA==", + "dependencies": { + "xunit.v3.common": "[3.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "r3jg64kY+8rGwG4IBq1+ELDlXvolcgvf707+Un8AeC6tmVsymOt6H00Kiptf3iPqjsnE3WsFJROI5XowdrbmAQ==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "eV9DTPLuMmzQwMTPAkSHijBQUVLu6IoA1+/PkmtMFbU+EqA0EDriZ266EhlRBk7v+ymP8hliSQW/bGXJQEOBFw==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.8.2", + "Microsoft.Testing.Platform": "1.8.2", + "xunit.v3.extensibility.core": "[3.0.1]", + "xunit.v3.runner.common": "[3.0.1]" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.media": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.providers.openrouter": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Media": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.Remote.Tests/OpenGameAgent.Providers.Remote.Tests.csproj b/tests/OpenGameAgent.Providers.Remote.Tests/OpenGameAgent.Providers.Remote.Tests.csproj new file mode 100644 index 0000000..17f7165 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Remote.Tests/OpenGameAgent.Providers.Remote.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Providers.Remote.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs b/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs new file mode 100644 index 0000000..c3ae644 --- /dev/null +++ b/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs @@ -0,0 +1,922 @@ +using System.Net; +using System.Text; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Providers.Remote.Tests; + +public sealed class RemoteModelProviderTests +{ + [Fact] + public async Task RoundTripsNormalizedRequestStreamTerminalHeadersAndCredentials() + { + var upstream = new ScriptedProvider(FullStream()); + var server = new ModelProviderProxyServer( + upstream, + new ModelProviderProxyServerOptions { ApiKey = "server-secret" }); + var handler = new LoopbackHandler(server); + var remote = CreateRemote(handler, options => + { + options.ApiKey = "server-secret"; + options.Headers = new Dictionary { ["x-game-build"] = "42" }; + }); + var request = ComplexRequest(); + + var events = await CollectAsync(remote.StreamAsync(request, TestContext.Current.CancellationToken)); + Assert.False( + events.Count == 1 && events[0].Kind == ModelStreamEventKind.Failed, + events.Count == 1 ? events[0].Response?.ErrorMessage : null); + + Assert.Equal( + new[] + { + ModelStreamEventKind.Started, + ModelStreamEventKind.ReasoningStarted, + ModelStreamEventKind.ReasoningDelta, + ModelStreamEventKind.ReasoningEnded, + ModelStreamEventKind.TextStarted, + ModelStreamEventKind.TextDelta, + ModelStreamEventKind.TextEnded, + ModelStreamEventKind.ToolCallStarted, + ModelStreamEventKind.ToolCallDelta, + ModelStreamEventKind.ToolCallEnded, + ModelStreamEventKind.Completed, + }, + events.Select(value => value.Kind)); + + var reasoningEnded = Assert.Single(events, value => value.Kind == ModelStreamEventKind.ReasoningEnded); + Assert.Equal("plan", reasoningEnded.Content); + var streamedReasoning = Assert.IsType( + reasoningEnded.Partial!.Content[reasoningEnded.ContentIndex]); + Assert.Equal("reason-signature", streamedReasoning.Signature); + + var textEnded = Assert.Single(events, value => value.Kind == ModelStreamEventKind.TextEnded); + Assert.Equal("hello", textEnded.Content); + var streamedText = Assert.IsType(textEnded.Partial!.Content[textEnded.ContentIndex]); + Assert.Equal("text-signature", streamedText.Signature); + Assert.Equal(AgentTextPhase.Commentary, streamedText.Phase); + + var toolStarted = Assert.Single(events, value => value.Kind == ModelStreamEventKind.ToolCallStarted); + var toolDelta = Assert.Single(events, value => value.Kind == ModelStreamEventKind.ToolCallDelta); + var toolEnded = Assert.Single(events, value => value.Kind == ModelStreamEventKind.ToolCallEnded); + Assert.Equal(toolStarted.ContentIndex, toolDelta.ContentIndex); + Assert.Equal(toolStarted.ContentIndex, toolEnded.ContentIndex); + Assert.Equal("move", toolStarted.ToolName); + Assert.Equal("move", toolDelta.ToolName); + Assert.Equal("{\"x\":1}", Assert.IsType(toolDelta.Partial!.Content[2]).ArgumentsJson); + var streamedCall = Assert.IsType(toolEnded.ToolCall); + Assert.Equal("call-1", streamedCall.Id); + Assert.Equal("move", streamedCall.Name); + Assert.Equal("{\"x\":1}", streamedCall.ArgumentsJson); + Assert.Equal("tool-signature", streamedCall.ThoughtSignature); + Assert.Equal("world", streamedCall.Namespace); + + var terminal = events[^1]; + var response = terminal.Response!; + Assert.Equal(ModelStopReason.ToolUse, response.StopReason); + Assert.Equal("upstream", response.Provider); + Assert.Equal("native-api", response.Api); + Assert.Equal("served-model", response.ResponseModel); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("tool_use", response.RawStopReason); + Assert.False(response.EndTurn); + Assert.Equal(11, response.Usage.InputTokens); + Assert.Equal(7, response.Usage.OutputTokens); + Assert.Equal(2, response.Usage.CacheReadTokens); + Assert.Equal(3, response.Usage.CacheWriteTokens); + Assert.Equal(4, response.Usage.ReasoningTokens); + Assert.Equal(1, response.Usage.CacheWriteOneHourTokens); + Assert.Equal(0.11, response.Usage.Cost.Input); + Assert.Equal(0.07, response.Usage.Cost.Output); + Assert.Equal(0.02, response.Usage.Cost.CacheRead); + Assert.Equal(0.03, response.Usage.Cost.CacheWrite); + var diagnostic = Assert.Single(response.Diagnostics); + Assert.Equal("route", diagnostic.Code); + Assert.Equal(ModelDiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Equal("{\"region\":\"test\"}", diagnostic.DataJson); + AssertToolCallEqual(streamedCall, Assert.IsType(response.Content[2])); + + Assert.Equal("Bearer server-secret", handler.Authorization); + Assert.Equal("42", handler.GameBuild); + var captured = Assert.IsType(upstream.CapturedRequest); + AssertRequestEqual(request, captured); + } + + [Fact] + public async Task PreservesDeferredTerminalHandle() + { + var deferred = new DeferredModelHandle( + "upstream", + "model", + "batch-api", + "job-1", + DateTimeOffset.UnixEpoch.AddHours(1), + 250, + "{\"queue\":2}"); + var response = new ModelResponse( + Array.Empty(), + ModelStopReason.Deferred, + new ModelUsage(1), + provider: "upstream", + api: "batch-api", + responseModel: "model", + responseId: "response-2", + rawStopReason: "deferred", + deferred: deferred); + var upstream = new ScriptedProvider(new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()), + ModelStreamEvent.Terminal(response), + }); + var remote = CreateRemote(new LoopbackHandler(new ModelProviderProxyServer(upstream))); + + var events = await CollectAsync(remote.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + var terminal = events[^1].Response!; + Assert.Equal(ModelStopReason.Deferred, terminal.StopReason); + Assert.Equal("job-1", terminal.Deferred!.Id); + Assert.Equal(DateTimeOffset.UnixEpoch.AddHours(1), terminal.Deferred.ExpiresAt); + Assert.Equal(250, terminal.Deferred.PollAfterMilliseconds); + Assert.Equal("{\"queue\":2}", terminal.Deferred.DataJson); + } + + [Fact] + public async Task AuthenticationFailureIsAnInBandTerminalAndSkipsProvider() + { + var upstream = new ScriptedProvider(FullStream()); + var server = new ModelProviderProxyServer( + upstream, + new ModelProviderProxyServerOptions { ApiKey = "correct" }); + var remote = CreateRemote( + new LoopbackHandler(server), + options => options.ApiKey = "wrong"); + + var events = await CollectAsync(remote.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + var terminal = Assert.Single(events); + Assert.Equal(ModelStreamEventKind.Failed, terminal.Kind); + Assert.Equal(ModelStopReason.Error, terminal.Response!.StopReason); + Assert.Contains("Unauthorized", terminal.Response.ErrorMessage, StringComparison.Ordinal); + Assert.Equal(0, upstream.Calls); + } + + [Fact] + public async Task InvalidUpstreamOrderFailsClosedWithOneErrorTerminal() + { + var pending = Pending(); + var invalid = new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, pending), + ModelStreamEvent.Update( + ModelStreamEventKind.TextDelta, + new ModelResponse(new AgentContent[] { new TextContent("orphan") }, ModelStopReason.Pending), + "orphan", + 0), + }; + var remote = CreateRemote( + new LoopbackHandler(new ModelProviderProxyServer(new ScriptedProvider(invalid)))); + + var events = await CollectAsync(remote.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Started, events[0].Kind); + var terminal = Assert.Single(events, value => value.IsTerminal); + Assert.Equal(ModelStreamEventKind.Failed, terminal.Kind); + Assert.Contains("missing or ended", terminal.Response!.ErrorMessage, StringComparison.Ordinal); + Assert.DoesNotContain(events, value => value.Kind == ModelStreamEventKind.TextDelta); + } + + [Fact] + public async Task EventAfterBufferedTerminalReplacesSuccessWithOneErrorTerminal() + { + var pending = Pending(); + var invalid = new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, pending), + ModelStreamEvent.Terminal(new ModelResponse(Array.Empty(), ModelStopReason.Stop)), + ModelStreamEvent.Update(ModelStreamEventKind.Started, pending), + }; + var remote = CreateRemote( + new LoopbackHandler(new ModelProviderProxyServer(new ScriptedProvider(invalid)))); + + var events = await CollectAsync(remote.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(2, events.Count); + Assert.Equal(ModelStreamEventKind.Started, events[0].Kind); + Assert.Equal(ModelStreamEventKind.Failed, events[1].Kind); + Assert.Contains("after its terminal", events[1].Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task ClientRejectsWireDataAfterTerminal() + { + var server = new ModelProviderProxyServer(new ScriptedProvider(new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()), + ModelStreamEvent.Terminal(new ModelResponse(Array.Empty(), ModelStopReason.Stop)), + })); + var remote = CreateRemote(new AppendAfterTerminalHandler(server)); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(remote.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + + Assert.Contains("after its terminal", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ClientEnforcesRequestEventResponseAndDepthLimits() + { + var server = new ModelProviderProxyServer(new ScriptedProvider(new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()), + ModelStreamEvent.Terminal(new ModelResponse(Array.Empty(), ModelStopReason.Stop)), + })); + + var requestLimited = CreateRemote( + new LoopbackHandler(server), + options => options.MaximumRequestBytes = 32); + await Assert.ThrowsAsync(async () => + await CollectAsync(requestLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + + var eventLimited = CreateRemote( + new LoopbackHandler(server), + options => options.MaximumEvents = 2); + await Assert.ThrowsAsync(async () => + await CollectAsync(eventLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + + var responseLimited = CreateRemote( + new LoopbackHandler(server), + options => options.MaximumResponseBytes = 64); + await Assert.ThrowsAsync(async () => + await CollectAsync(responseLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + + var depthLimited = CreateRemote( + new RawSseHandler("data:{\"t\":\"s\",\"v\":1,\"r\":{\"unknown\":[[[[[0]]]]]}}\n\n"), + options => options.MaximumJsonDepth = 4); + await Assert.ThrowsAsync(async () => + await CollectAsync(depthLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + } + + [Fact] + public async Task ServerRequestDepthAndEventLimitsFailInBand() + { + var requestLimitedUpstream = new ScriptedProvider(FullStream()); + var requestLimitedServer = new ModelProviderProxyServer( + requestLimitedUpstream, + new ModelProviderProxyServerOptions { MaximumRequestBytes = 64 }); + var requestLimited = CreateRemote(new LoopbackHandler(requestLimitedServer)); + + var requestEvents = await CollectAsync( + requestLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(requestEvents).Kind); + Assert.Contains("request exceeded", requestEvents[0].Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Equal(0, requestLimitedUpstream.Calls); + + var depthLimitedUpstream = new ScriptedProvider(FullStream()); + var depthLimitedServer = new ModelProviderProxyServer( + depthLimitedUpstream, + new ModelProviderProxyServerOptions { MaximumJsonDepth = 4 }); + var depthLimited = CreateRemote( + new ReplaceRequestBodyHandler( + depthLimitedServer, + "{\"v\":1,\"r\":{\"m\":\"model\",\"deep\":[[[[[0]]]]]}}")); + + var depthEvents = await CollectAsync( + depthLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Failed, Assert.Single(depthEvents).Kind); + Assert.Contains("valid JSON", depthEvents[0].Response!.ErrorMessage, StringComparison.Ordinal); + Assert.Equal(0, depthLimitedUpstream.Calls); + + var eventLimitedUpstream = new ScriptedProvider(FullStream()); + var eventLimitedServer = new ModelProviderProxyServer( + eventLimitedUpstream, + new ModelProviderProxyServerOptions { MaximumEvents = 2 }); + var eventLimited = CreateRemote(new LoopbackHandler(eventLimitedServer)); + + var eventResults = await CollectAsync( + eventLimited.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(ModelStreamEventKind.Started, eventResults[0].Kind); + Assert.Equal(ModelStreamEventKind.ReasoningStarted, eventResults[1].Kind); + Assert.Equal(ModelStreamEventKind.Failed, eventResults[2].Kind); + Assert.Contains("event limit", eventResults[2].Response!.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task CancellationReachesWrappedProvider() + { + var upstream = new BlockingProvider(); + var remote = CreateRemote( + new LoopbackHandler(new ModelProviderProxyServer(upstream))); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + + await Assert.ThrowsAnyAsync(async () => + await CollectAsync(remote.StreamAsync(SimpleRequest(), cancellation.Token))); + + Assert.True(upstream.CancellationObserved); + } + + [Fact] + public async Task MultilineSseEventUsesIncrementalUtf8LimitAccounting() + { + const int exactEventBytes = 8192; + var stream = new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()), + ModelStreamEvent.Terminal(new ModelResponse(Array.Empty(), ModelStopReason.Stop)), + }; + var acceptedHandler = new MultilineSetupHandler( + new ModelProviderProxyServer(new ScriptedProvider(stream)), + exactEventBytes); + var accepted = CreateRemote( + acceptedHandler, + options => options.MaximumEventBytes = exactEventBytes); + + var events = await CollectAsync( + accepted.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(exactEventBytes, acceptedHandler.ReconstructedEventBytes); + Assert.Equal(ModelStreamEventKind.Completed, events[^1].Kind); + + var rejectedHandler = new MultilineSetupHandler( + new ModelProviderProxyServer(new ScriptedProvider(stream)), + exactEventBytes); + var rejected = CreateRemote( + rejectedHandler, + options => options.MaximumEventBytes = exactEventBytes - 1); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(rejected.StreamAsync(SimpleRequest(), TestContext.Current.CancellationToken))); + Assert.Contains("event exceeded", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CancellationObservesReadStreamFaultThatArrivesLater() + { + var marker = "late-remote-stream-fault-" + Guid.NewGuid().ToString("N"); + var unobserved = 0; + EventHandler listener = (_, eventArgs) => + { + if (eventArgs.Exception.Flatten().InnerExceptions.Any(value => value.Message == marker)) + { + Interlocked.Exchange(ref unobserved, 1); + eventArgs.SetObserved(); + } + }; + TaskScheduler.UnobservedTaskException += listener; + try + { + var pendingTask = await CancelBeforeReadStreamFaultAsync(marker); + for (var attempt = 0; attempt < 10 && pendingTask.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + Assert.False(pendingTask.IsAlive); + Assert.Equal(0, Volatile.Read(ref unobserved)); + } + finally + { + TaskScheduler.UnobservedTaskException -= listener; + } + } + + [Fact] + public async Task DisposingStreamingContentSuppressesProviderCancellationCallbackFailures() + { + var provider = new ThrowingCancellationCallbackProvider(); + var server = new ModelProviderProxyServer(provider); + using var request = new HttpRequestMessage( + HttpMethod.Post, + "https://proxy.example.test/v1/model-stream") + { + Content = new StringContent( + "{\"v\":1,\"r\":{\"m\":\"model\",\"s\":\"\",\"g\":[],\"o\":[],\"p\":{},\"r\":\"run\",\"n\":1}}", + Encoding.UTF8, + "application/json"), + }; + using var response = await server.HandleAsync(request, TestContext.Current.CancellationToken); + var copyTask = response.Content.CopyToAsync(Stream.Null, TestContext.Current.CancellationToken); + await provider.CallbackRegistered.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + + var exception = Record.Exception(response.Dispose); + + Assert.Null(exception); + await Assert.ThrowsAnyAsync(() => copyTask); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task CancelBeforeReadStreamFaultAsync(string marker) + { + LateFaultingReadContent? content = new(); + LateFaultingReadHandler? handler = new(content); + RemoteModelProvider? remote = CreateRemote(handler); + using var cancellation = new CancellationTokenSource(); + Task>? pending = CollectAsync( + remote.StreamAsync(SimpleRequest(), cancellation.Token)); + await content.ReadRequested.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + var weakTask = new WeakReference(content.PendingStreamTask); + content.Fail(new InvalidOperationException(marker)); + await Task.Delay(25); + + pending = null; + remote = null; + handler = null; + content = null; + return weakTask; + } + + private static RemoteModelProvider CreateRemote( + HttpMessageHandler handler, + Action? configure = null) + { + var options = new RemoteModelProviderOptions( + new HttpClient(handler), + new Uri("https://proxy.example.test/v1/model-stream")); + configure?.Invoke(options); + return new RemoteModelProvider(options); + } + + private static ModelRequest SimpleRequest() => new( + "model", + string.Empty, + Array.Empty(), + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + private static ModelRequest ComplexRequest() + { + var priorCall = new ToolCallContent("prior-call", "inspect", "{\"target\":\"npc\"}", "prior-signature", "world"); + var usage = new ModelUsage(2, 1, cost: new ModelCost(0.02, 0.01)); + var messages = new AgentMessage[] + { + new( + AgentRole.User, + new AgentContent[] + { + new TextContent("look", "user-signature", AgentTextPhase.FinalAnswer), + new JsonContent("{\"tick\":1.5}"), + new ResourceContent("game://npc/1", "application/json", "npc"), + new BinaryContent(AgentMediaKind.Image, "aW1hZ2U=", "image/png", "portrait"), + }, + DateTimeOffset.UnixEpoch, + metadata: new Dictionary { ["scene"] = "village" }), + new( + AgentRole.Custom, + new AgentContent[] { new JsonContent("{\"weather\":\"rain\"}") }, + DateTimeOffset.UnixEpoch.AddSeconds(1), + customRole: "world_state"), + new( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("prior plan", "reasoning-signature"), + priorCall, + }, + DateTimeOffset.UnixEpoch.AddSeconds(2), + model: "old-model", + stopReason: ModelStopReason.ToolUse, + usage: usage, + provider: "old-provider", + api: "old-api", + responseModel: "served-old-model", + responseId: "old-response", + rawStopReason: "tool_use", + endTurn: false, + diagnostics: new[] { new ModelDiagnostic("old", "Old route") }), + AgentMessage.ToolResult( + priorCall, + new ToolResult( + new AgentContent[] { new TextContent("clear"), new ResourceContent("game://result/1", "text/plain") }, + detailsJson: "{\"latency\":5}", + usage: usage, + addedToolNames: new[] { "move" }), + DateTimeOffset.UnixEpoch.AddSeconds(3)), + }; + return new ModelRequest( + "game-model", + "rules", + messages, + new[] + { + new ToolDefinition( + "move", + "Move in the world", + "{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"}}}", + ToolConstrainedSampling.Grammar(openAiRegex: "[0-9]+")), + }, + new ModelParameters + { + Temperature = 0.2, + MaxOutputTokens = 123, + ReasoningLevel = "high", + ReasoningBudgets = new Dictionary { ["high"] = 4096 }, + SamplingParametersJson = "{\"top_p\":0.9}", + Transport = ModelTransport.ServerSentEvents, + CacheRetention = ModelCacheRetention.Long, + WebSocketConnectTimeoutMilliseconds = 500, + Deferred = true, + DeferredWindow = ModelDeferredWindow.OneHour, + MetadataJson = "{\"trace\":true}", + Extensions = new Dictionary { ["route"] = "fast" }, + }, + "session-1", + "run-1", + 2); + } + + private static IReadOnlyList FullStream() + { + var reasoning = new ReasoningContent("plan", "reason-signature"); + var text = new TextContent("hello", "text-signature", AgentTextPhase.Commentary); + var call = new ToolCallContent("call-1", "move", "{\"x\":1}", "tool-signature", "world"); + var content = new AgentContent[] { reasoning, text, call }; + var setup = Pending(); + var reasoningStarted = Pending(new ReasoningContent(string.Empty, "reason-signature")); + var reasoningComplete = Pending(reasoning); + var textStarted = Pending(reasoning, new TextContent(string.Empty, "text-signature", AgentTextPhase.Commentary)); + var textComplete = Pending(reasoning, text); + var toolStarted = Pending(reasoning, text, new ToolCallContent("call-1", "move", "{}", "tool-signature", "world")); + var allComplete = Pending(content); + var usage = new ModelUsage( + 11, + 7, + 2, + 3, + 4, + 1, + new ModelCost(0.11, 0.07, 0.02, 0.03)); + var response = new ModelResponse( + content, + ModelStopReason.ToolUse, + usage, + provider: "upstream", + api: "native-api", + responseModel: "served-model", + responseId: "response-1", + rawStopReason: "tool_use", + endTurn: false, + diagnostics: new[] + { + new ModelDiagnostic("route", "Fallback route", ModelDiagnosticSeverity.Warning, "{\"region\":\"test\"}"), + }); + return new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, setup), + ModelStreamEvent.Update(ModelStreamEventKind.ReasoningStarted, reasoningStarted, contentIndex: 0), + ModelStreamEvent.Update(ModelStreamEventKind.ReasoningDelta, reasoningComplete, "plan", 0), + ModelStreamEvent.Update(ModelStreamEventKind.ReasoningEnded, reasoningComplete, contentIndex: 0, content: "plan"), + ModelStreamEvent.Update(ModelStreamEventKind.TextStarted, textStarted, contentIndex: 1), + ModelStreamEvent.Update(ModelStreamEventKind.TextDelta, textComplete, "hello", 1), + ModelStreamEvent.Update(ModelStreamEventKind.TextEnded, textComplete, contentIndex: 1, content: "hello"), + ModelStreamEvent.Update(ModelStreamEventKind.ToolCallStarted, toolStarted, contentIndex: 2, toolCallId: "call-1"), + ModelStreamEvent.Update(ModelStreamEventKind.ToolCallDelta, allComplete, "{\"x\":1}", 2, "call-1", "move"), + ModelStreamEvent.Update(ModelStreamEventKind.ToolCallEnded, allComplete, contentIndex: 2, toolCall: call), + ModelStreamEvent.Terminal(response), + }; + } + + private static ModelResponse Pending(params AgentContent[] content) => new( + content, + ModelStopReason.Pending, + provider: "upstream", + api: "native-api", + responseModel: "served-model", + responseId: "response-1"); + + private static async Task> CollectAsync( + IAsyncEnumerable stream) + { + var result = new List(); + await foreach (var item in stream) + { + result.Add(item); + } + + return result; + } + + private static void AssertToolCallEqual(ToolCallContent expected, ToolCallContent actual) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.ArgumentsJson, actual.ArgumentsJson); + Assert.Equal(expected.ThoughtSignature, actual.ThoughtSignature); + Assert.Equal(expected.Namespace, actual.Namespace); + } + + private static void AssertRequestEqual(ModelRequest expected, ModelRequest actual) + { + Assert.Equal(expected.Model, actual.Model); + Assert.Equal(expected.SystemPrompt, actual.SystemPrompt); + Assert.Equal(expected.SessionId, actual.SessionId); + Assert.Equal(expected.RunId, actual.RunId); + Assert.Equal(expected.Turn, actual.Turn); + Assert.Equal(expected.Messages.Count, actual.Messages.Count); + Assert.Equal(expected.Messages[0].Timestamp, actual.Messages[0].Timestamp); + Assert.Equal("village", actual.Messages[0].Metadata["scene"]); + Assert.Equal("{\"tick\":1.5}", Assert.IsType(actual.Messages[0].Content[1]).Json); + Assert.Equal(AgentMediaKind.Image, Assert.IsType(actual.Messages[0].Content[3]).MediaKind); + Assert.Equal("world_state", actual.Messages[1].CustomRole); + Assert.Equal("old-response", actual.Messages[2].ResponseId); + Assert.Equal("move", Assert.Single(actual.Messages[3].AddedToolNames)); + var tool = Assert.Single(actual.Tools); + Assert.Equal("move", tool.Name); + Assert.Equal(ToolConstrainedSamplingKind.Grammar, tool.ConstrainedSampling!.Kind); + Assert.Equal("[0-9]+", tool.ConstrainedSampling.OpenAiRegex); + Assert.Equal(0.2, actual.Parameters.Temperature); + Assert.Equal(123, actual.Parameters.MaxOutputTokens); + Assert.Equal(4096, actual.Parameters.ReasoningBudgets["high"]); + Assert.Equal(ModelTransport.ServerSentEvents, actual.Parameters.Transport); + Assert.Equal(ModelCacheRetention.Long, actual.Parameters.CacheRetention); + Assert.True(actual.Parameters.Deferred); + Assert.Equal(ModelDeferredWindow.OneHour, actual.Parameters.DeferredWindow); + Assert.Equal("fast", actual.Parameters.Extensions["route"]); + } + + private sealed class ScriptedProvider : IModelProvider + { + private readonly IReadOnlyList _events; + + public ScriptedProvider(IReadOnlyList events) + { + _events = events; + } + + public int Calls { get; private set; } + + public ModelRequest? CapturedRequest { get; private set; } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Calls++; + CapturedRequest = request; + foreach (var item in _events) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return item; + } + } + } + + private sealed class BlockingProvider : IModelProvider + { + public bool CancellationObserved { get; private set; } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + CancellationObserved = true; + throw; + } + } + } + + private sealed class ThrowingCancellationCallbackProvider : IModelProvider + { + public TaskCompletionSource CallbackRegistered { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register( + static () => throw new InvalidOperationException("hostile cancellation callback")); + CallbackRegistered.TrySetResult(true); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } + + private sealed class LoopbackHandler : HttpMessageHandler + { + private readonly ModelProviderProxyServer _server; + + public LoopbackHandler(ModelProviderProxyServer server) + { + _server = server; + } + + public string? Authorization { get; private set; } + + public string? GameBuild { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Authorization = request.Headers.TryGetValues("Authorization", out var authorization) + ? authorization.Single() + : null; + GameBuild = request.Headers.TryGetValues("x-game-build", out var gameBuild) + ? gameBuild.Single() + : null; + return _server.HandleAsync(request, cancellationToken); + } + } + + private sealed class AppendAfterTerminalHandler : HttpMessageHandler + { + private readonly ModelProviderProxyServer _server; + + public AppendAfterTerminalHandler(ModelProviderProxyServer server) + { + _server = server; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + using var original = await _server.HandleAsync(request, cancellationToken); + var body = await original.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + body + "data:{\"t\":\"e\",\"k\":0}\n\n", + Encoding.UTF8, + "text/event-stream"), + }; + } + } + + private sealed class RawSseHandler : HttpMessageHandler + { + private readonly string _body; + + public RawSseHandler(string body) + { + _body = body; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_body, Encoding.UTF8, "text/event-stream"), + }); + } + + private sealed class MultilineSetupHandler : HttpMessageHandler + { + private const int MiddleLines = 128; + private readonly ModelProviderProxyServer _server; + private readonly int _targetBytes; + + public MultilineSetupHandler(ModelProviderProxyServer server, int targetBytes) + { + _server = server; + _targetBytes = targetBytes; + } + + public int ReconstructedEventBytes { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + using var original = await _server.HandleAsync(request, cancellationToken); + var body = await original.Content.ReadAsStringAsync(cancellationToken); + var eventEnd = body.IndexOf("\n\n", StringComparison.Ordinal); + if (eventEnd < 0 || !body.StartsWith("data:", StringComparison.Ordinal)) + { + throw new InvalidDataException("The generated proxy response did not contain a setup frame."); + } + + var json = body.Substring(5, eventEnd - 5) + .Replace("upstream", "上游", StringComparison.Ordinal); + var split = json.IndexOf(',', StringComparison.Ordinal) + 1; + if (split <= 0) + { + throw new InvalidDataException("The generated setup frame cannot be split safely."); + } + + var jsonBytes = Encoding.UTF8.GetByteCount(json); + var newlineBytes = MiddleLines + 1; + var paddingBytes = _targetBytes - jsonBytes - newlineBytes; + if (paddingBytes < MiddleLines) + { + throw new InvalidDataException("The target setup frame size is too small for the multiline fixture."); + } + + var transformed = new StringBuilder(body.Length + paddingBytes + (MiddleLines * 6)); + transformed.Append("data:").Append(json, 0, split).Append('\n'); + var remaining = paddingBytes; + for (var index = 0; index < MiddleLines; index++) + { + var count = remaining / (MiddleLines - index); + transformed.Append("data:").Append('\t', count).Append('\n'); + remaining -= count; + } + + transformed.Append("data:").Append(json, split, json.Length - split).Append("\n\n"); + transformed.Append(body, eventEnd + 2, body.Length - eventEnd - 2); + ReconstructedEventBytes = jsonBytes + newlineBytes + paddingBytes; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(transformed.ToString(), Encoding.UTF8, "text/event-stream"), + }; + } + } + + private sealed class LateFaultingReadHandler : HttpMessageHandler + { + private readonly LateFaultingReadContent _content; + + public LateFaultingReadHandler(LateFaultingReadContent content) + { + _content = content; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = _content }); + } + + private sealed class LateFaultingReadContent : HttpContent + { + private readonly TaskCompletionSource _stream = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public LateFaultingReadContent() + { + Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/event-stream"); + } + + public TaskCompletionSource ReadRequested { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task PendingStreamTask => _stream.Task; + + public void Fail(Exception exception) => _stream.TrySetException(exception); + + protected override Task CreateContentReadStreamAsync() + { + ReadRequested.TrySetResult(true); + return _stream.Task; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } + + private sealed class ReplaceRequestBodyHandler : HttpMessageHandler + { + private readonly ModelProviderProxyServer _server; + private readonly string _body; + + public ReplaceRequestBodyHandler(ModelProviderProxyServer server, string body) + { + _server = server; + _body = body; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + using var replacement = new HttpRequestMessage(HttpMethod.Post, request.RequestUri) + { + Content = new StringContent(_body, Encoding.UTF8, "application/json"), + }; + foreach (var header in request.Headers) + { + replacement.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return await _server.HandleAsync(replacement, cancellationToken); + } + } +} diff --git a/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json new file mode 100644 index 0000000..6233e5a --- /dev/null +++ b/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json @@ -0,0 +1,220 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.8.1, )", + "resolved": "18.8.1", + "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==", + "dependencies": { + "Microsoft.CodeCoverage": "18.8.1", + "Microsoft.TestPlatform.TestHost": "18.8.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Security.AccessControl": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.8.1", + "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.8.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.providers.remote": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Server.Tests/ServerTests.cs b/tests/OpenGameAgent.Server.Tests/ServerTests.cs index 287bed7..148e703 100644 --- a/tests/OpenGameAgent.Server.Tests/ServerTests.cs +++ b/tests/OpenGameAgent.Server.Tests/ServerTests.cs @@ -697,7 +697,14 @@ public async IAsyncEnumerable StreamAsync( new ModelResponse(Array.Empty(), ModelStopReason.Pending)); yield return ModelStreamEvent.Update( ModelStreamEventKind.ToolCallDelta, - new ModelResponse(Array.Empty(), ModelStopReason.Pending), + new ModelResponse( + new AgentContent[] + { + new TextContent("prefix"), + new ReasoningContent("plan"), + new ToolCallContent("call-2", "move", "{}"), + }, + ModelStopReason.Pending), "{}", contentIndex: 2, toolCallId: "call-2", diff --git a/tests/OpenGameAgent.Server.Tests/packages.lock.json b/tests/OpenGameAgent.Server.Tests/packages.lock.json index 9ec18e4..f1076ab 100644 --- a/tests/OpenGameAgent.Server.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Server.Tests/packages.lock.json @@ -233,7 +233,8 @@ "opengameagent.extensions": { "type": "Project", "dependencies": { - "OpenGameAgent": "[0.3.0-alpha.1, )" + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Models": "[0.3.0-alpha.1, )" } }, "opengameagent.kernel": { @@ -242,6 +243,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, "opengameagent.persistence": { "type": "Project", "dependencies": { @@ -254,9 +261,13 @@ "type": "Project", "dependencies": { "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.providertransport": { + "type": "Project" + }, "opengameagent.server": { "type": "Project", "dependencies": { diff --git a/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs b/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs index 3fa4555..1e461ee 100644 --- a/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs +++ b/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs @@ -391,7 +391,7 @@ public async Task BeforeToolHooksComposeRevalidatedArgumentsAndCannotBypassLater "rewrite", _ => new AgentHooks { - BeforeToolCallAsync = (_, _, _) => new ValueTask( + BeforeToolCallAsync = (_, _) => new ValueTask( ToolCallDecision.Allow("{\"value\":2}")), }, priority: 10)) @@ -399,10 +399,9 @@ public async Task BeforeToolHooksComposeRevalidatedArgumentsAndCannotBypassLater "policy", _ => new AgentHooks { - BeforeToolCallAsync = (call, _, _) => + BeforeToolCallAsync = (context, _) => { - using var arguments = JsonDocument.Parse(call.ArgumentsJson); - policySawValue = arguments.RootElement.GetProperty("value").GetInt32(); + policySawValue = context.Arguments.GetProperty("value").GetInt32(); return new ValueTask(ToolCallDecision.Block("denied")); }, })) diff --git a/tests/OpenGameAgent.Tests/GameResourceTests.cs b/tests/OpenGameAgent.Tests/GameResourceTests.cs new file mode 100644 index 0000000..0ae43a9 --- /dev/null +++ b/tests/OpenGameAgent.Tests/GameResourceTests.cs @@ -0,0 +1,86 @@ +using Xunit; + +namespace OpenGameAgent.Tests; + +public sealed class GameResourceTests +{ + [Fact] + public void PromptTemplateFormatterParsesQuotesAndSubstitutesAllSupportedFormsOnce() + { + var arguments = GamePromptTemplateFormatter.ParseArguments( + "command \"first value\" 'second value'\nthird"); + var template = new GamePromptTemplate( + "example", + "$1|$2|${@:2}|${@:2:2}|$@|$ARGUMENTS"); + + var result = GamePromptTemplateFormatter.Format(template, arguments); + + Assert.Equal( + "command|first value|first value second value third|first value second value|command first value second value third|command first value second value third", + result); + Assert.Equal( + "$ARGUMENTS $1", + GamePromptTemplateFormatter.Substitute("$1 $2", new[] { "$ARGUMENTS", "$1" })); + } + + [Fact] + public void PromptTemplateFormatterHandlesMissingIndicesSlicesUnicodeAndEmptyQuotes() + { + var arguments = new[] { "日本語", "🎉", "café" }; + + Assert.Equal("||日本語 🎉 café|🎉 café|", GamePromptTemplateFormatter.Substitute( + "$0|$9|${@:0}|${@:2}|${@:2:0}", + arguments)); + Assert.Equal("日本語.5", GamePromptTemplateFormatter.Substitute("$1.5", arguments)); + Assert.Equal( + new[] { " " }, + GamePromptTemplateFormatter.ParseArguments("\"\" \" \"").ToArray()); + Assert.Equal( + new[] { "line1\nline2", "second" }, + GamePromptTemplateFormatter.ParseArguments("\"line1\nline2\" second").ToArray()); + } + + [Fact] + public async Task DisabledSkillsRemainDiscoverableButAreExcludedFromAutomaticSelection() + { + var visible = new GameSkill("visible", "Visible", "", "Visible instructions."); + var explicitOnly = new GameSkill( + "explicit", + "Explicit", + "", + "Explicit instructions.", + disableModelInvocation: true); + var source = new InMemoryGameSkillSource(new[] { visible, explicitOnly }); + var query = new GameSkillQuery( + new GameInput("session", "actor", "chat", "{}", new GameMoment("world", 1)), + Array.Empty(), + 10); + + var selected = await source.SelectAsync(query, TestContext.Current.CancellationToken); + + Assert.Equal("visible", Assert.Single(selected).SkillId); + Assert.True(explicitOnly.DisableModelInvocation); + } + + [Fact] + public void SkillInvocationIncludesEscapedProvenanceAndRelativeReferenceBase() + { + var source = new GameResourceSourceInfo( + "package", + "C:\\game\\skills", + "C:\\game\\skills\\inspect\\SKILL.md", + "project"); + var skill = new GameSkill( + "inspect", + "inspect&verify", + "Inspect", + "Use inspection tools.", + sourceInfo: source); + + var invocation = GameSkillFormatter.FormatInvocation(skill, "Check errors."); + + Assert.Contains("inspect&verify", invocation, StringComparison.Ordinal); + Assert.Contains("C:\\game\\skills\\inspect", invocation, StringComparison.Ordinal); + Assert.EndsWith("\n\nCheck errors.", invocation, StringComparison.Ordinal); + } +} diff --git a/tests/OpenGameAgent.Tests/GameSessionHistoryTests.cs b/tests/OpenGameAgent.Tests/GameSessionHistoryTests.cs new file mode 100644 index 0000000..e9f87af --- /dev/null +++ b/tests/OpenGameAgent.Tests/GameSessionHistoryTests.cs @@ -0,0 +1,238 @@ +using OpenGameAgent.Kernel; +using Xunit; + +#pragma warning disable xUnit1051 // Individual operations are in-memory; cancellation behavior has a dedicated test. + +namespace OpenGameAgent.Tests; + +public sealed class GameSessionHistoryTests +{ + [Fact] + public async Task SharedLogSupportsLanesBranchesRecordsFactsAndCursors() + { + var repository = new InMemoryGameSessionHistoryRepository(); + var history = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "session" }); + var root = await history.AppendEntryAsync("root", "turn", "{\"value\":1}", mutationId: "m1", expectedSequence: 0); + await history.CreateLaneAsync("npc", root.Entry.Id, mutationId: "m2", expectedSequence: 1); + var main = await history.AppendEntryAsync("main", "turn", "{\"value\":2.5}", mutationId: "m3", expectedSequence: 2); + var npc = await history.AppendEntryAsync("npc", "turn", "{\"value\":3}", lane: "npc", mutationId: "m4", expectedSequence: 3); + var record = await history.AppendRecordAsync("decision", "trace", "{\"why\":\"goal\"}", lane: "npc", mutationId: "m5"); + await history.SetNameAsync("Example", mutationId: "m6"); + await history.SetLabelAsync(npc.Entry.Id, "checkpoint", mutationId: "m7"); + + Assert.Equal(1, root.Entry.Sequence); + Assert.Equal(3, main.Entry.Sequence); + Assert.Equal(4, npc.Entry.Sequence); + Assert.Equal(5, record.Record.Sequence); + Assert.Equal(new[] { "root", "main" }, (await history.FindBranchAsync( + query: new GameHistoryBranchQuery { Order = GameHistoryOrder.OldestFirst })).Items.Select(entry => entry.Id)); + Assert.Equal(new[] { "root", "npc" }, (await history.FindBranchAsync( + "npc", + new GameHistoryBranchQuery { Order = GameHistoryOrder.OldestFirst })).Items.Select(entry => entry.Id)); + Assert.Equal(npc.Entry.Id, await history.View("npc").GetLeafEntryIdAsync()); + var npcTail = await history.View("npc").AppendEntryAsync("npc-tail", "turn", "{}"); + Assert.Equal(npc.Entry.Id, npcTail.Entry.ParentId); + Assert.Equal("Example", await history.GetNameAsync()); + Assert.Equal("checkpoint", await history.GetLabelAsync("npc")); + Assert.Equal(new[] { 3L, 4L }, (await history.FindEntriesAsync(new GameHistoryEntryQuery + { + Order = GameHistoryOrder.OldestFirst, + CursorSequence = 1, + Limit = 2, + })).Items.Select(entry => entry.Sequence)); + Assert.Equal(new[] { 1L, 2L, 3L }, (await history.GetLogAsync(new GameHistoryLogQuery { Limit = 3 })).Items.Select(item => item.Sequence)); + Assert.Equal(3, (await history.GetLogAsync(new GameHistoryLogQuery { Limit = 3 })).NextSequence); + Assert.Equal(8, (await history.GetStatsAsync()).LastSequence); + } + + [Fact] + public async Task MutationIdsAreIdempotentAndExpectedSequenceFailsClosed() + { + var history = await new InMemoryGameSessionHistoryRepository().CreateAsync( + new GameHistoryCreateOptions { Id = "session" }); + + var first = await history.AppendEntryAsync("entry", "event", "{}", mutationId: "stable", expectedSequence: 0); + var retry = await history.AppendEntryAsync("entry", "event", "{}", mutationId: "stable", expectedSequence: 999); + + Assert.True(retry.Commit.Replayed); + Assert.Equal(first.Entry.Sequence, retry.Entry.Sequence); + await Assert.ThrowsAsync(() => + history.AppendEntryAsync("next", "event", "{}", mutationId: "next-mutation", expectedSequence: 0)); + var mismatch = await Assert.ThrowsAsync(() => + history.AppendEntryAsync("different", "event", "{}", mutationId: "stable")); + Assert.Equal(GameHistoryErrorCode.Conflict, mismatch.Code); + Assert.Equal(1, (await history.GetStatsAsync()).MutationCount); + } + + [Fact] + public async Task ForkCopiesASelectedBranchOrCompleteTreeWithoutOperationalRecords() + { + var repository = new InMemoryGameSessionHistoryRepository(); + var source = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "source" }); + var root = await source.AppendEntryAsync("root", "turn", "{}"); + var shared = await source.AppendEntryAsync("shared", "turn", "{}"); + await source.CreateLaneAsync("npc", shared.Entry.Id); + var main = await source.AppendEntryAsync("main", "turn", "{}"); + var npc = await source.AppendEntryAsync("npc", "turn", "{}", lane: "npc"); + await source.AppendRecordAsync("run", "operation", "{}"); + await source.SetNameAsync("World A"); + await source.SetLabelAsync(shared.Entry.Id, "shared-label"); + await source.SetLabelAsync(npc.Entry.Id, "npc-label"); + + var branch = await repository.ForkAsync("source", new GameHistoryForkOptions + { + Id = "branch", + EntryId = main.Entry.Id, + Position = GameHistoryForkPosition.At, + }); + var tree = await repository.ForkAsync("source", new GameHistoryForkOptions + { + Id = "tree", + Scope = GameHistoryForkScope.Tree, + }); + + Assert.Equal(new[] { "root", "shared", "main" }, (await branch.FindEntriesAsync(new GameHistoryEntryQuery + { + Order = GameHistoryOrder.OldestFirst, + })).Items.Select(entry => entry.Id)); + Assert.Empty((await branch.FindRecordsAsync()).Items); + Assert.Equal("shared-label", await branch.GetLabelAsync(shared.Entry.Id)); + Assert.Null(await branch.GetLabelAsync(npc.Entry.Id)); + Assert.Equal("World A", await branch.GetNameAsync()); + Assert.Equal(new[] { "main", "npc" }, (await tree.GetLanesAsync()).Select(lane => lane.Name)); + Assert.Equal(npc.Entry.Id, (await tree.GetLanesAsync()).Single(lane => lane.Name == "npc").LeafEntryId); + Assert.Empty((await tree.FindRecordsAsync()).Items); + Assert.Equal("source", (await branch.GetMetadataAsync()).ParentSessionId); + Assert.Equal(root.Entry.ParentId, (await branch.GetEntryAsync("root"))!.ParentId); + } + + [Fact] + public async Task ContextProjectionIsComposableBoundedAndNotTextOnly() + { + var history = await new InMemoryGameSessionHistoryRepository().CreateAsync( + new GameHistoryCreateOptions { Id = "session" }); + await history.AppendEntryAsync("old", "event", "{\"ignored\":true}"); + await history.AppendEntryAsync("checkpoint", "context_checkpoint", "{\"summary\":\"state\"}"); + await history.AppendEntryAsync("binary-input", "controller_input", "{\"axis\":0.75,\"buttons\":[1,0]}"); + + var projection = await history.BuildContextAsync( + "main", + new GameHistoryContextOptions + { + EntryTransform = GameHistoryContextTransforms.AfterLatest("context_checkpoint"), + EntryProjector = entry => entry.Type == "controller_input" + ? new[] { AgentMessage.UserJson(entry.PayloadJson) } + : Array.Empty(), + StateProjector = entries => $"{{\"entryCount\":{entries.Count}}}", + }); + + Assert.Single(projection.Messages); + var json = Assert.IsType(Assert.Single(projection.Messages[0].Content)); + Assert.Contains("0.75", json.Json, StringComparison.Ordinal); + Assert.Equal("{\"entryCount\":2}", projection.StateJson); + } + + [Fact] + public async Task RepositoryListSearchDeleteAndConcurrentWritesAreBounded() + { + var repository = new InMemoryGameSessionHistoryRepository(new GameHistoryLimits + { + MaxSessions = 20, + MaxEntriesPerSession = 50, + MaxRecordsPerSession = 10, + MaxMutationsPerSession = 100, + MaxLanesPerSession = 5, + DefaultQueryResults = 2, + MaxQueryResults = 10, + MaxSearchResults = 5, + }); + var first = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "a" }); + await repository.CreateAsync(new GameHistoryCreateOptions { Id = "b" }); + var writes = Enumerable.Range(0, 20).Select(index => + first.AppendEntryAsync($"entry-{index}", "event", $"{{\"search\":\"needle {index}\"}}")); + var committed = await Task.WhenAll(writes); + + Assert.Equal(20, committed.Select(value => value.Entry.Sequence).Distinct().Count()); + var firstList = await repository.ListAsync(new GameHistoryListQuery { Limit = 1 }); + Assert.Single(firstList.Sessions); + Assert.NotNull(firstList.NextSessionId); + Assert.Single((await repository.ListAsync(new GameHistoryListQuery + { + Limit = 1, + AfterSessionId = firstList.NextSessionId, + })).Sessions); + var search = await repository.SearchAsync(new GameHistorySearchQuery("needle") { Limit = 3 }); + Assert.Equal(3, search.Hits.Count); + Assert.NotNull(search.NextCursor); + Assert.NotEmpty((await repository.SearchAsync(new GameHistorySearchQuery("needle") + { + Limit = 3, + Cursor = search.NextCursor, + })).Hits); + + await repository.DeleteAsync("a"); + await repository.DeleteAsync("a"); + await Assert.ThrowsAsync(() => repository.OpenAsync("a")); + } + + [Fact] + public async Task InvalidJsonLimitsQueriesAndCancellationDoNotMutateState() + { + var history = await new InMemoryGameSessionHistoryRepository(new GameHistoryLimits + { + MaxSessions = 2, + MaxEntriesPerSession = 2, + MaxRecordsPerSession = 0, + MaxMutationsPerSession = 10, + MaxLanesPerSession = 2, + MaxPayloadCharacters = 16, + DefaultQueryResults = 1, + MaxQueryResults = 2, + }).CreateAsync(new GameHistoryCreateOptions { Id = "session" }); + + await Assert.ThrowsAsync(() => history.AppendEntryAsync("bad", "event", "{not-json}")); + await Assert.ThrowsAsync(() => history.FindEntriesAsync(new GameHistoryEntryQuery { Limit = 3 })); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + history.AppendEntryAsync("cancelled", "event", "{}", cancellationToken: cancellation.Token)); + Assert.Equal(0, (await history.GetStatsAsync()).MutationCount); + } + + [Fact] + public async Task SearchScanAndContextCallbacksHaveHardBounds() + { + var limits = new GameHistoryLimits + { + MaxSessions = 2, + MaxEntriesPerSession = 10, + MaxRecordsPerSession = 1, + MaxMutationsPerSession = 20, + MaxLanesPerSession = 2, + DefaultQueryResults = 2, + MaxQueryResults = 10, + MaxSearchResults = 5, + MaxSearchScannedEntries = 2, + }; + var repository = new InMemoryGameSessionHistoryRepository(limits); + var history = await repository.CreateAsync(new GameHistoryCreateOptions { Id = "session" }); + await history.AppendEntryAsync("one", "event", "{}"); + await history.AppendEntryAsync("two", "event", "{}"); + await history.AppendEntryAsync("three", "event", "{\"match\":true}"); + + var scanError = await Assert.ThrowsAsync(() => + repository.SearchAsync(new GameHistorySearchQuery("match"))); + Assert.Equal(GameHistoryErrorCode.LimitExceeded, scanError.Code); + var callbackError = await Assert.ThrowsAsync(() => history.BuildContextAsync( + new GameHistoryContextOptions + { + CallbackTimeout = TimeSpan.FromMilliseconds(20), + EntryProjector = _ => + { + Thread.Sleep(200); + return Array.Empty(); + }, + })); + Assert.Equal(GameHistoryErrorCode.LimitExceeded, callbackError.Code); + } +} diff --git a/tests/OpenGameAgent.Tests/OpenGameAgent.Tests.csproj b/tests/OpenGameAgent.Tests/OpenGameAgent.Tests.csproj index e6e6917..494d287 100644 --- a/tests/OpenGameAgent.Tests/OpenGameAgent.Tests.csproj +++ b/tests/OpenGameAgent.Tests/OpenGameAgent.Tests.csproj @@ -17,4 +17,7 @@ + + + diff --git a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs new file mode 100644 index 0000000..2db464b --- /dev/null +++ b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs @@ -0,0 +1,21 @@ +using OpenGameAgent.Testing; +using Xunit; + +namespace OpenGameAgent.Tests; + +public sealed class PublicApiCompatibilityTests +{ + private const string ApprovedApiHash = "293E301CD64AC756A502F0DAE2B0A167C296B9DF6259B8E595F8921DAA394A84"; + + [Fact] + public void RuntimePublicApiMatchesTheApprovedStableSurface() + { + var assembly = typeof(GameAgentRuntime).Assembly; + var surface = PublicApiSurface.Describe(assembly); + var hash = PublicApiSurface.Hash(assembly); + + Assert.True( + string.Equals(ApprovedApiHash, hash, StringComparison.Ordinal), + $"The runtime public API changed. Review the complete surface below, then update the approved hash intentionally.\nHash: {hash}\n\n{surface}"); + } +} diff --git a/tests/OpenGameAgent.Tests/RuntimeTests.cs b/tests/OpenGameAgent.Tests/RuntimeTests.cs index 8fac137..d9ad1c4 100644 --- a/tests/OpenGameAgent.Tests/RuntimeTests.cs +++ b/tests/OpenGameAgent.Tests/RuntimeTests.cs @@ -1458,7 +1458,11 @@ public async Task MediaToolStreamsProgressAndReturnsResourceContent() var result = await agent.RunAsync(AgentMessage.UserJson("{}"), TestContext.Current.CancellationToken); Assert.True(result.Succeeded); - Assert.Equal(0.5, Assert.Single(progress).Fraction); + var update = Assert.Single(progress); + Assert.Equal(0.5, update.Fraction); + Assert.Equal( + "cHJldmlldw==", + Assert.Single(update.Content.OfType()).Data); var toolMessage = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); var resource = Assert.Single(toolMessage.Content.OfType()); Assert.Equal("image/png", resource.MediaType); @@ -1466,132 +1470,1137 @@ public async Task MediaToolStreamsProgressAndReturnsResourceContent() } [Fact] - public async Task TranscriptCompactionPreservesCompleteToolExchange() + public async Task TranscriptCompactionPreservesCompleteToolExchange() + { + var call = new ToolCallContent("call", "act", "{}"); + var toolResult = new ToolResult(new AgentContent[] { new TextContent("ok") }); + var messages = new AgentMessage[] + { + AgentMessage.User("old"), + Assistant("old answer"), + AgentMessage.User("keep"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult(call, toolResult, DateTimeOffset.UnixEpoch), + Assistant("after tool"), + AgentMessage.User("latest"), + Assistant("latest answer"), + }; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + new ValueTask(new GameTranscriptSummaryResult("summary:" + removed.Count))); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 7), + TestContext.Current.CancellationToken); + + Assert.Equal(7, compacted.Messages.Count); + Assert.Equal("transcript_summary", compacted.Messages[0].CustomRole); + Assert.Contains(compacted.Messages, message => message.Content.OfType().Any(item => item.Id == "call")); + Assert.Contains(compacted.Messages, message => message.Role == AgentRole.Tool && message.ToolCallId == "call"); + } + + [Fact] + public async Task TranscriptCompactionCanSummarizeTheEntireTranscript() + { + var call = new ToolCallContent("call", "act", "{}"); + var messages = new AgentMessage[] + { + AgentMessage.User("old"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("ok") }), DateTimeOffset.UnixEpoch), + Assistant("finished"), + }; + IReadOnlyList? summarized = null; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + { + summarized = removed; + return new ValueTask(new GameTranscriptSummaryResult("complete summary")); + }); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 1), + TestContext.Current.CancellationToken); + + Assert.Equal(messages, summarized); + var summary = Assert.Single(compacted.Messages); + Assert.Equal("transcript_summary", summary.CustomRole); + Assert.Equal("complete summary", Assert.IsType(Assert.Single(summary.Content)).Text); + } + + [Fact] + public async Task TranscriptCompactionHonorsATokenTargetEvenWhenMessageCountFits() + { + var messages = new AgentMessage[] + { + AgentMessage.User(new string('a', 200)), + Assistant(new string('b', 200)), + AgentMessage.User("recent"), + Assistant("recent answer"), + }; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + new ValueTask(new GameTranscriptSummaryResult("short summary:" + removed.Count))); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + messages, + targetMessageCount: 10, + targetEstimatedTokens: 100, + tokenEstimator: ApproximateGameTokenEstimator.EstimateMessages), + TestContext.Current.CancellationToken); + + Assert.True(compacted.Messages.Count < messages.Length); + Assert.Equal("transcript_summary", compacted.Messages[0].CustomRole); + Assert.True(ApproximateGameTokenEstimator.EstimateMessages(compacted.Messages) <= 100); + } + + [Fact] + public async Task TranscriptCompactionReturnsSummaryUsageAndTypedDetails() + { + var messages = new AgentMessage[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }; + var usage = new ModelUsage( + 7, + 3, + reasoningTokens: 2, + cost: new ModelCost(input: 0.07, output: 0.06)); + var compactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask( + new GameTranscriptSummaryResult("complete summary", usage, "{\"provider\":\"summary\"}"))); + + var result = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + messages, + targetMessageCount: 1, + targetEstimatedTokens: 100, + tokenEstimator: ApproximateGameTokenEstimator.EstimateMessages), + TestContext.Current.CancellationToken); + + Assert.Same(usage, result.Usage); + Assert.Equal(4, result.Details.OriginalMessageCount); + Assert.Equal(4, result.Details.CompactedMessageCount); + Assert.Equal(0, result.Details.RetainedMessageCount); + Assert.NotNull(result.Details.EstimatedTokensBefore); + Assert.Equal("{\"provider\":\"summary\"}", result.Details.SummaryDetailsJson); + Assert.Equal("transcript_summary", Assert.Single(result.Messages).CustomRole); + } + + [Fact] + public async Task RepeatedTranscriptCompactionUpdatesThePriorSummaryWithOnlyNewHistory() + { + var requests = new List(); + var compactor = new SummarizingGameTranscriptCompactor((request, _) => + { + requests.Add(request); + var text = request.PreviousSummary is null ? "first summary" : "updated summary"; + return new ValueTask( + GameTranscriptSummaryAttemptResult.Success(text, new ModelUsage(1, 1))); + }); + var key = new GameSessionKey("session", "actor"); + var first = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + key, + new[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }, + targetMessageCount: 3), + TestContext.Current.CancellationToken); + var secondSource = first.Messages + .Concat(new[] { AgentMessage.User("three"), Assistant("three") }) + .ToArray(); + + var second = await compactor.CompactAsync( + new GameTranscriptCompactionContext(key, secondSource, targetMessageCount: 3), + TestContext.Current.CancellationToken); + + Assert.Equal(2, requests.Count); + Assert.Null(requests[0].PreviousSummary); + Assert.Equal(new[] { "one", "one" }, requests[0].Messages.Select(MessageText)); + Assert.Equal("first summary", requests[1].PreviousSummary); + Assert.Equal(new[] { "two", "two" }, requests[1].Messages.Select(MessageText)); + Assert.Equal(3, requests[1].SourceMessages.Count); + Assert.DoesNotContain(requests[1].Messages, message => message.CustomRole == "transcript_summary"); + Assert.True(second.Details.PreviousSummaryUsed); + Assert.Equal(2, second.Details.IncrementalMessageCount); + Assert.Equal("updated summary", MessageText(second.Messages[0])); + } + + [Fact] + public async Task TranscriptCompactionDoesNotTreatCustomMessagesInsideToolExchangesAsTurnBoundaries() + { + var call = new ToolCallContent("safe-call", "act", "{}"); + var source = new AgentMessage[] + { + AgentMessage.User("old"), + Assistant("old"), + AgentMessage.User("build"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + new(AgentRole.Custom, new AgentContent[] { new TextContent("progress") }, DateTimeOffset.UnixEpoch, customRole: "world_event"), + AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("built") }), DateTimeOffset.UnixEpoch), + Assistant("finished"), + AgentMessage.User("latest"), + Assistant("latest"), + }; + IReadOnlyList? summarized = null; + var compactor = new SummarizingGameTranscriptCompactor((_, messages, _) => + { + summarized = messages; + return new ValueTask(new GameTranscriptSummaryResult("safe summary")); + }); + + var result = await compactor.CompactAsync( + new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), source, 6), + TestContext.Current.CancellationToken); + + Assert.NotNull(summarized); + Assert.Contains(summarized, message => message.Content.OfType().Any(item => item.Id == call.Id)); + Assert.Contains(summarized, message => message.Role == AgentRole.Tool && message.ToolCallId == call.Id); + Assert.DoesNotContain(result.Messages, message => message.ToolCallId == call.Id); + Assert.Equal(7, result.Details.CutMessageIndex); + Assert.Equal(1, result.Details.RetainedTurnCount); + } + + [Fact] + public async Task TranscriptSummaryRetriesAggregateEveryAttemptUsageAndExposeAttemptDetails() + { + var seenPreviousErrors = new List(); + var compactor = new SummarizingGameTranscriptCompactor((request, _) => + { + seenPreviousErrors.Add(request.PreviousError); + return request.Attempt == 1 + ? new ValueTask( + GameTranscriptSummaryAttemptResult.Failure( + "temporary provider failure", + new ModelUsage(3, 1, cost: new ModelCost(input: 0.3, output: 0.1)), + retryable: true, + detailsJson: "{\"attempt\":1}")) + : new ValueTask( + GameTranscriptSummaryAttemptResult.Success( + "summary", + new ModelUsage(2, 1, cost: new ModelCost(input: 0.2, output: 0.1)), + "{\"attempt\":2}")); + }, maxSummaryAttempts: 2); + + var result = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + new[] { AgentMessage.User("one"), Assistant("one") }, + targetMessageCount: 1), + TestContext.Current.CancellationToken); + + Assert.Equal(new string?[] { null, "temporary provider failure" }, seenPreviousErrors); + Assert.Equal(7, result.Usage.TotalTokens); + Assert.Equal(0.7, result.Usage.Cost.Total, precision: 10); + Assert.Equal(2, result.Details.SummaryAttemptCount); + Assert.Equal(1, result.Details.FailedSummaryAttemptCount); + Assert.False(result.Details.SummaryAttempts[0].Succeeded); + Assert.True(result.Details.SummaryAttempts[0].Retryable); + Assert.True(result.Details.SummaryAttempts[1].Succeeded); + Assert.Equal(GameTranscriptCompactionTrigger.MessageLimit, result.Details.Trigger); + } + + [Fact] + public async Task OversizedTranscriptSummaryIsRetriedAndChargesBothModelCalls() + { + var previousErrors = new List(); + var compactor = new SummarizingGameTranscriptCompactor((request, _) => + { + previousErrors.Add(request.PreviousError); + var summary = request.Attempt == 1 ? new string('x', 1_000) : "ok"; + return new ValueTask( + GameTranscriptSummaryAttemptResult.Success(summary, new ModelUsage(1, 1))); + }, maxSummaryAttempts: 2); + + var result = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + new[] { AgentMessage.User(new string('u', 100)), Assistant(new string('a', 100)) }, + targetMessageCount: 1, + targetEstimatedTokens: 80, + tokenEstimator: ApproximateGameTokenEstimator.EstimateMessages), + TestContext.Current.CancellationToken); + + Assert.Null(previousErrors[0]); + Assert.Contains("token target", previousErrors[1], StringComparison.Ordinal); + Assert.Equal(4, result.Usage.TotalTokens); + Assert.Equal(2, result.Details.SummaryAttemptCount); + Assert.False(result.Details.SummaryAttempts[0].Succeeded); + Assert.True(result.Details.SummaryAttempts[1].Succeeded); + Assert.Equal("ok", MessageText(Assert.Single(result.Messages))); + } + + [Fact] + public async Task FailedSummaryDoesNotStartAnotherRetryAfterItsRunUsageBudgetIsExhausted() + { + var attempts = 0; + var compactor = new SummarizingGameTranscriptCompactor((_, _) => + { + attempts++; + return new ValueTask( + GameTranscriptSummaryAttemptResult.Failure( + "temporary failure", + new ModelUsage(2, 1), + retryable: true)); + }, maxSummaryAttempts: 3); + + var exception = await Assert.ThrowsAsync(async () => + await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + new[] { AgentMessage.User("one"), Assistant("one") }, + targetMessageCount: 1, + maximumSummaryUsageTokens: 3), + TestContext.Current.CancellationToken)); + + Assert.Equal(1, attempts); + Assert.Equal("summary_usage_limit_exceeded", exception.ErrorCode); + Assert.Equal(3, exception.Usage.TotalTokens); + Assert.Single(exception.Details.SummaryAttempts); + Assert.False(exception.Details.Applied); + Assert.Equal(exception.ErrorCode, exception.Details.FailureCode); + } + + [Fact] + public async Task FailedTranscriptSummaryPersistsAllRetryUsageWithoutProcessingTheInput() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + var original = new[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }; + await store.SaveAsync( + new GameSessionSnapshot(key, 1, original), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(_ => Text("must not run")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 5 }, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((request, _) => + new ValueTask( + GameTranscriptSummaryAttemptResult.Failure( + "summary service unavailable", + new ModelUsage(2, 1, cost: new ModelCost(input: 0.2, output: 0.1)), + retryable: true, + detailsJson: "{\"attempt\":" + request.Attempt + "}")), + maxSummaryAttempts: 2), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}", "failed-summary-usage"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.Failed, result.Status); + Assert.Contains("summary service unavailable", result.Error, StringComparison.Ordinal); + Assert.Equal(0, provider.CallCount); + Assert.NotNull(saved); + Assert.Equal(2, saved.Revision); + Assert.Equal(original, saved.Messages); + Assert.DoesNotContain("failed-summary-usage", saved.ProcessedInputIds); + Assert.Equal(6, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + var usageRecord = Assert.Single(saved.UsageLedger.Records); + Assert.Contains("\"SummaryAttemptCount\":2", usageRecord.DetailsJson, StringComparison.Ordinal); + Assert.Contains("\"Applied\":false", usageRecord.DetailsJson, StringComparison.Ordinal); + Assert.Contains("\"FailureCode\":\"summary_failed\"", usageRecord.DetailsJson, StringComparison.Ordinal); + } + + [Fact] + public async Task AppliedUsageOnlyCasConflictDoesNotDuplicateFailedSummaryCharges() + { + var store = new AppliedButReportedConflictOnSecondSaveSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }), + 0, + TestContext.Current.CancellationToken); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( + new RecordingProvider(_ => Text("must not run")), + "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 5 }, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _) => + new ValueTask( + GameTranscriptSummaryAttemptResult.Failure( + "failed", + new ModelUsage(2, 1), + retryable: false))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}", "failed-summary-cas"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.Failed, result.Status); + Assert.Equal(2, store.SaveCalls); + Assert.NotNull(saved); + Assert.Single(saved.UsageLedger.Records); + Assert.Equal(3, saved.UsageLedger.Stats.TotalTokens); + } + + [Fact] + public async Task BranchSummaryHelperSelectsACompleteRecentTurnWithoutSessionTreeState() + { + var call = new ToolCallContent("branch-call", "act", "{}"); + var source = new AgentMessage[] + { + AgentMessage.User("old"), + Assistant("old"), + AgentMessage.User("branch action"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("done") }), DateTimeOffset.UnixEpoch), + Assistant("branch finished"), + }; + GameTranscriptSummaryContext? request = null; + var summarizer = new GameBranchSummarizer((context, _) => + { + request = context; + return new ValueTask( + GameTranscriptSummaryAttemptResult.Success("branch summary", new ModelUsage(2, 1))); + }); + + var result = await summarizer.SummarizeAsync( + new GameSessionKey("session", "actor"), + source, + targetEstimatedTokens: 40, + messages => messages.Count * 10L, + TestContext.Current.CancellationToken); + + Assert.NotNull(request); + Assert.Equal(GameTranscriptSummaryPurpose.Branch, request.Purpose); + Assert.Equal(4, request.Messages.Count); + Assert.Contains(request.Messages, message => message.Content.OfType().Any(item => item.Id == call.Id)); + Assert.Contains(request.Messages, message => message.Role == AgentRole.Tool && message.ToolCallId == call.Id); + Assert.Equal(2, result.Details.OmittedMessageCount); + Assert.Equal(3, result.Usage.TotalTokens); + } + + [Fact] + public async Task TranscriptSummaryUsageCountsTowardTheRunTokenLimit() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(_ => Text("answer")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 5, MaxTotalTokens = 10 }, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask( + new GameTranscriptSummaryResult("summary", new ModelUsage(6, 3)))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}", "usage-budget"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.Failed, result.Status); + Assert.Contains("including transcript compaction", result.Error, StringComparison.Ordinal); + Assert.Equal(1, provider.CallCount); + Assert.NotNull(saved); + Assert.Equal(11, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(9, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + Assert.Equal(2, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + } + + [Fact] + public async Task OverBudgetTranscriptSummaryPreventsTheNextModelRequest() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("one"), + Assistant("one"), + AgentMessage.User("two"), + Assistant("two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(_ => Text("must not run")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 5, MaxTotalTokens = 5 }, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask( + new GameTranscriptSummaryResult("summary", new ModelUsage(4, 2)))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}", "summary-over-budget"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.Failed, result.Status); + Assert.Equal(0, provider.CallCount); + Assert.NotNull(saved); + Assert.Single(saved.UsageLedger.Records, record => record.Cause == GameSessionUsageCause.Compaction); + Assert.Equal(6, saved.UsageLedger.Stats.TotalTokens); + } + + [Fact] + public async Task UsageLedgerSurvivesRepeatedCompactionAndRuntimeRestart() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + var provider = new RecordingProvider(_ => new ModelResponse( + new AgentContent[] { new TextContent("answer") }, + ModelStopReason.Stop, + new ModelUsage(2, 1, cost: new ModelCost(input: 0.2, output: 0.1)))); + var compactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask( + new GameTranscriptSummaryResult( + "summary", + new ModelUsage(3, 2, cost: new ModelCost(input: 0.3, output: 0.2))))); + + GameAgentRuntime CreateRuntime() => new(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 3 }, + TranscriptCompactor = compactor, + }); + + await using (var runtime = CreateRuntime()) + { + Assert.True((await runtime.RunAsync( + Input("chat", "{}", "usage-one"), + TestContext.Current.CancellationToken)).Succeeded); + Assert.True((await runtime.RunAsync( + Input("chat", "{}", "usage-two"), + TestContext.Current.CancellationToken)).Succeeded); + } + + await using (var restarted = CreateRuntime()) + { + Assert.True((await restarted.RunAsync( + Input("chat", "{}", "usage-three"), + TestContext.Current.CancellationToken)).Succeeded); + } + + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + Assert.NotNull(saved); + Assert.Equal(3, saved.Messages.Count); + Assert.Equal(5, saved.UsageLedger.Records.Count); + Assert.Equal(19, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(10, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + Assert.Equal(9, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + Assert.Equal(1.9, saved.UsageLedger.Stats.CostTotal, precision: 10); + } + + [Fact] + public async Task LegacyMessageUsageIsBootstrappedBeforeCompactionRemovesHistory() + { + static AgentMessage LegacyAssistant(string text, ModelUsage usage) => new( + AgentRole.Assistant, + new AgentContent[] { new TextContent(text) }, + DateTimeOffset.UnixEpoch, + model: "legacy-model", + stopReason: ModelStopReason.Stop, + usage: usage); + + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("one"), + LegacyAssistant("one", new ModelUsage(3, 1)), + AgentMessage.User("two"), + LegacyAssistant("two", new ModelUsage(4, 2)), + }), + 0, + TestContext.Current.CancellationToken); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( + new RecordingProvider(_ => Text("answer")), + "model") + { + SessionStore = store, + AgentLimits = new AgentLimits { MaxMessages = 5 }, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask( + new GameTranscriptSummaryResult("summary", new ModelUsage(2, 1)))), + }); + + Assert.True((await runtime.RunAsync( + Input("chat", "{}", "legacy-compaction"), + TestContext.Current.CancellationToken)).Succeeded); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(saved); + Assert.Equal(4, saved.UsageLedger.Records.Count); + Assert.Equal(15, saved.UsageLedger.Stats.TotalTokens); + Assert.Contains(saved.UsageLedger.Records, record => record.RecordId == "legacy-message-1"); + Assert.Contains(saved.UsageLedger.Records, record => record.RecordId == "legacy-message-3"); + } + + [Fact] + public async Task AppliedCasConflictRetryDoesNotDuplicateUsage() + { + var store = new AppliedButReportedConflictSessionStore(); + var provider = new RecordingProvider(_ => Text("answer")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + }); + var input = Input("chat", "{}", "cas-usage"); + + var first = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var retry = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var saved = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.SessionConflict, first.Status); + Assert.Equal(GameAgentRunStatus.Duplicate, retry.Status); + Assert.Equal(1, provider.CallCount); + Assert.NotNull(saved); + Assert.Single(saved.UsageLedger.Records); + Assert.Equal(2, saved.UsageLedger.Stats.TotalTokens); + } + + [Fact] + public async Task LosingCasAttemptSettlesUsageOnceBeforeARealRetry() + { + var store = new ConflictOnceSessionStore(); + var provider = new RecordingProvider(_ => Text("answer")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + }); + var input = Input("chat", "{}", "losing-cas-usage"); + + var conflicted = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var retried = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var saved = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + + Assert.Equal(GameAgentRunStatus.SessionConflict, conflicted.Status); + Assert.True(retried.Succeeded); + Assert.Equal(2, provider.CallCount); + Assert.NotNull(saved); + Assert.Equal(2, saved.UsageLedger.Records.Count); + Assert.Equal(4, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(3, saved.Revision); + } + + [Fact] + public void UsageLedgerAppendIsIdempotentAndRejectsRecordIdentityReuse() + { + var record = new GameSessionUsageRecord( + "usage-record", + GameSessionUsageCause.Assistant, + new ModelUsage(2, 1), + "run", + "input"); + var ledger = new GameSessionUsageLedger(new[] { record }); + + var replayed = ledger.Append(new[] { record }); + + Assert.Same(ledger, replayed); + Assert.Single(replayed.Records); + Assert.Throws(() => replayed.Append(new[] + { + new GameSessionUsageRecord( + record.RecordId, + record.Cause, + new ModelUsage(8, 1), + record.RunId, + record.InputId), + })); + } + + [Fact] + public async Task UsageLedgerBoundsRecentRecordsWithoutLosingCumulativeStats() + { + var records = Enumerable.Range(0, 10) + .Select(index => new GameSessionUsageRecord( + "bounded-" + index, + GameSessionUsageCause.Assistant, + new ModelUsage(1, 1, cost: new ModelCost(input: 0.01, output: 0.02)))) + .ToArray(); + var ledger = new GameSessionUsageLedger(records, recentRecordCapacity: 3); + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("bounded-session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, usageLedger: ledger), + 0, + TestContext.Current.CancellationToken); + + var loaded = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.Equal(3, loaded.UsageLedger.Records.Count); + Assert.Equal(10, loaded.UsageLedger.TotalRecordCount); + Assert.Equal(20, loaded.UsageLedger.Stats.TotalTokens); + Assert.Equal(0.3, loaded.UsageLedger.Stats.CostTotal, precision: 10); + Assert.Equal(new[] { "bounded-7", "bounded-8", "bounded-9" }, + loaded.UsageLedger.Records.Select(record => record.RecordId)); + } + + [Fact] + public async Task ToolModelUsageIsIncludedInTheSessionLedger() + { + var store = new InMemoryGameSessionStore(); + var provider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("usage-tool", "inspect", "{}")) + : Text("done")); + var tool = new AgentTool( + new ToolDefinition("inspect", "inspect", "{\"type\":\"object\"}"), + (_, _, _) => new ValueTask(new ToolResult( + new AgentContent[] { new TextContent("inspected") }, + usage: new ModelUsage(3, 1, cost: new ModelCost(input: 0.03, output: 0.02))))); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ToolProvider = (_, _) => new ValueTask>(new[] { tool }), + }); + + var result = await runtime.RunAsync( + Input("inspect", "{}", "tool-usage"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.NotNull(saved); + Assert.Equal(3, saved.UsageLedger.Records.Count); + Assert.Equal(8, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(4, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + Assert.Equal(4, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Tool).TotalTokens); + Assert.Equal(0.05, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Tool).CostTotal, precision: 10); + } + + [Fact] + public async Task RuntimeCompactsBeforeAnEstimatedContextWindowOverflow() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + var history = new AgentMessage[] + { + AgentMessage.User(new string('a', 1_200)), + Assistant(new string('b', 1_200)), + AgentMessage.User(new string('c', 1_200)), + Assistant(new string('d', 1_200)), + }; + await store.SaveAsync( + new GameSessionSnapshot(key, 1, history), + 0, + TestContext.Current.CancellationToken); + var compacted = false; + var provider = new RecordingProvider(_ => Text("done")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + { + compacted = true; + return new ValueTask( + new GameTranscriptSummaryResult("summary:" + removed.Count)); + }), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.True(compacted); + Assert.Equal(1, provider.CallCount); + var request = Assert.Single(provider.Requests); + Assert.True(ApproximateGameTokenEstimator.EstimateRequest( + request.Model, + request.SystemPrompt, + request.Messages, + request.Tools) <= 900); + } + + [Fact] + public async Task RuntimeRecoversOnceFromAProviderReportedContextOverflowBeforeOutput() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(call => call == 1 + ? new ModelResponse( + Array.Empty(), + ModelStopReason.Length, + new ModelUsage(inputTokens: 990)) + : Text("recovered")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask(new GameTranscriptSummaryResult( + "older history", + new ModelUsage(inputTokens: 2, outputTokens: 1)))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{\"text\":\"keep this exact input\"}"), + TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, provider.CallCount); + var requests = provider.Requests.ToArray(); + Assert.Equal(5, requests[0].Messages.Count); + Assert.True(requests[1].Messages.Count < requests[0].Messages.Count); + Assert.Equal( + Assert.IsType(requests[0].Messages[^1].Content[0]).Json, + Assert.IsType(requests[1].Messages[^1].Content[0]).Json); + Assert.NotNull(saved); + Assert.Equal(995, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(992, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + Assert.Equal(3, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); + Assert.Contains(saved.UsageLedger.Records, record => + record.DetailsJson?.Contains("context_overflow_recovery", StringComparison.Ordinal) == true); + } + + [Fact] + public async Task RuntimeRecoversFromStructuredRequestTooLargeBeforeAStreamStarts() + { + var store = new InMemoryGameSessionStore(); + await store.SaveAsync( + new GameSessionSnapshot(new GameSessionKey("session", "actor"), 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RequestTooLargeThenResponseProvider(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask(new GameTranscriptSummaryResult("older history"))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task RuntimeRecoversFromAStructuredContextOverflowDiagnostic() + { + var store = new InMemoryGameSessionStore(); + await store.SaveAsync( + new GameSessionSnapshot(new GameSessionKey("session", "actor"), 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(call => call == 1 + ? new ModelResponse( + Array.Empty(), + ModelStopReason.Error, + errorMessage: "request rejected", + diagnostics: new[] + { + new ModelDiagnostic( + "provider_failure", + "structured provider error", + ModelDiagnosticSeverity.Error, + "{\"status\":400,\"errorCode\":\"model_context_window_exceeded\"}"), + }) + : Text("recovered")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask(new GameTranscriptSummaryResult("older history"))), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task RuntimeNeverReplaysAnOverflowAfterMeaningfulOutputWasExposed() + { + var compacted = false; + var provider = new MeaningfulOutputThenOverflowProvider(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + { + compacted = true; + return new ValueTask(new GameTranscriptSummaryResult("must not run")); + }), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}"), + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal(1, provider.CallCount); + Assert.False(compacted); + } + + [Fact] + public async Task RuntimeNeverReplaysAfterAToolMayHaveChangedTheGame() + { + var compacted = false; + var provider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("call", "inspect", "{}")) + : new ModelResponse( + Array.Empty(), + ModelStopReason.Length, + new ModelUsage(inputTokens: 990))); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + ToolProvider = (_, _) => new ValueTask>(new[] { ReadTool("inspect") }), + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + { + compacted = true; + return new ValueTask(new GameTranscriptSummaryResult("must not run")); + }), + }); + + var result = await runtime.RunAsync( + Input("inspect", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, provider.CallCount); + Assert.False(compacted); + } + + [Fact] + public async Task RuntimeDoesNotMisclassifyRateLimitsAsContextOverflow() { - var call = new ToolCallContent("call", "act", "{}"); - var toolResult = new ToolResult(new AgentContent[] { new TextContent("ok") }); - var messages = new AgentMessage[] + var provider = new RateLimitedProvider(); + var compacted = false; + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { - AgentMessage.User("old"), - Assistant("old answer"), - AgentMessage.User("keep"), - new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), - AgentMessage.ToolResult(call, toolResult, DateTimeOffset.UnixEpoch), - Assistant("after tool"), - AgentMessage.User("latest"), - Assistant("latest answer"), - }; - var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => - new ValueTask("summary:" + removed.Count)); + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + { + compacted = true; + return new ValueTask(new GameTranscriptSummaryResult("must not run")); + }), + }); - var compacted = await compactor.CompactAsync( - new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 7), + var result = await runtime.RunAsync( + Input("chat", "{}"), TestContext.Current.CancellationToken); - Assert.Equal(7, compacted.Count); - Assert.Equal("transcript_summary", compacted[0].CustomRole); - Assert.Contains(compacted, message => message.Content.OfType().Any(item => item.Id == "call")); - Assert.Contains(compacted, message => message.Role == AgentRole.Tool && message.ToolCallId == "call"); + Assert.False(result.Succeeded); + Assert.Equal(1, provider.CallCount); + Assert.False(compacted); } [Fact] - public async Task TranscriptCompactionCanSummarizeTheEntireTranscript() + public async Task FailedOverflowCompactionIsChargedWithoutReplayingTheProvider() { - var call = new ToolCallContent("call", "act", "{}"); - var messages = new AgentMessage[] - { - AgentMessage.User("old"), - new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), - AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("ok") }), DateTimeOffset.UnixEpoch), - Assistant("finished"), - }; - IReadOnlyList? summarized = null; - var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(_ => new ModelResponse( + Array.Empty(), + ModelStopReason.Length, + new ModelUsage(inputTokens: 990))); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { - summarized = removed; - return new ValueTask("complete summary"); + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor( + (_, _) => new ValueTask( + GameTranscriptSummaryAttemptResult.Failure( + "summary unavailable", + new ModelUsage(inputTokens: 5, outputTokens: 2), + retryable: false))), }); - var compacted = await compactor.CompactAsync( - new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 1), + var result = await runtime.RunAsync( + Input("chat", "{}"), TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); - Assert.Equal(messages, summarized); - var summary = Assert.Single(compacted); - Assert.Equal("transcript_summary", summary.CustomRole); - Assert.Equal("complete summary", Assert.IsType(Assert.Single(summary.Content)).Text); + Assert.True(result.Succeeded); + Assert.Equal(1, provider.CallCount); + Assert.NotNull(saved); + Assert.Equal(997, saved.UsageLedger.Stats.TotalTokens); + Assert.Equal(990, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); + Assert.Equal(7, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Compaction).TotalTokens); } [Fact] - public async Task TranscriptCompactionHonorsATokenTargetEvenWhenMessageCountFits() + public async Task AppliedCasConflictDoesNotDuplicateOverflowRecoveryUsage() { - var messages = new AgentMessage[] + var inner = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await inner.SaveAsync( + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), + 0, + TestContext.Current.CancellationToken); + var store = new AppliedButReportedConflictWrapper(inner); + var provider = new RecordingProvider(call => call == 1 + ? new ModelResponse( + Array.Empty(), + ModelStopReason.Length, + new ModelUsage(inputTokens: 990)) + : Text("recovered")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { - AgentMessage.User(new string('a', 200)), - Assistant(new string('b', 200)), - AgentMessage.User("recent"), - Assistant("recent answer"), - }; - var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => - new ValueTask("short summary:" + removed.Count)); + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => + new ValueTask(new GameTranscriptSummaryResult( + "older history", + new ModelUsage(inputTokens: 2, outputTokens: 1)))), + }); + var input = Input("chat", "{}", "overflow-cas"); - var compacted = await compactor.CompactAsync( - new GameTranscriptCompactionContext( - new GameSessionKey("session", "actor"), - messages, - targetMessageCount: 10, - targetEstimatedTokens: 100, - tokenEstimator: ApproximateGameTokenEstimator.EstimateMessages), - TestContext.Current.CancellationToken); + var conflicted = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var duplicate = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); - Assert.True(compacted.Count < messages.Length); - Assert.Equal("transcript_summary", compacted[0].CustomRole); - Assert.True(ApproximateGameTokenEstimator.EstimateMessages(compacted) <= 100); + Assert.Equal(GameAgentRunStatus.SessionConflict, conflicted.Status); + Assert.Equal(GameAgentRunStatus.Duplicate, duplicate.Status); + Assert.Equal(2, provider.CallCount); + Assert.NotNull(saved); + Assert.Equal(3, saved.UsageLedger.Records.Count); + Assert.Equal(995, saved.UsageLedger.Stats.TotalTokens); } [Fact] - public async Task RuntimeCompactsBeforeAnEstimatedContextWindowOverflow() + public async Task CancellingOverflowCompactionDoesNotReplayAndStillChargesReportedProviderUsage() { var store = new InMemoryGameSessionStore(); var key = new GameSessionKey("session", "actor"); - var history = new AgentMessage[] - { - AgentMessage.User(new string('a', 1_200)), - Assistant(new string('b', 1_200)), - AgentMessage.User(new string('c', 1_200)), - Assistant(new string('d', 1_200)), - }; await store.SaveAsync( - new GameSessionSnapshot(key, 1, history), + new GameSessionSnapshot(key, 1, new AgentMessage[] + { + AgentMessage.User("old one"), + Assistant("old one"), + AgentMessage.User("old two"), + Assistant("old two"), + }), 0, TestContext.Current.CancellationToken); - var compacted = false; - var provider = new RecordingProvider(_ => Text("done")); + var provider = new RecordingProvider(_ => new ModelResponse( + Array.Empty(), + ModelStopReason.Length, + new ModelUsage(inputTokens: 990))); + var compactionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { SessionStore = store, ContextWindowTokens = 1_000, ContextWindowReserveTokens = 100, - TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + TranscriptCompactor = new SummarizingGameTranscriptCompactor(async (_, cancellationToken) => { - compacted = true; - return new ValueTask("summary:" + removed.Count); + compactionStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return GameTranscriptSummaryAttemptResult.Success("unreachable"); }), }); + using var cancellation = new CancellationTokenSource(); - var result = await runtime.RunAsync( - Input("chat", "{}"), - TestContext.Current.CancellationToken); + var run = runtime.RunAsync(Input("chat", "{}"), cancellation.Token); + await compactionStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + var result = await run; + var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); - Assert.True(result.Succeeded); - Assert.True(compacted); + Assert.False(result.Succeeded); + Assert.Equal(AgentRunStatus.Aborted, result.AgentResult!.Status); Assert.Equal(1, provider.CallCount); - var request = Assert.Single(provider.Requests); - Assert.True(ApproximateGameTokenEstimator.EstimateRequest( - request.Model, - request.SystemPrompt, - request.Messages, - request.Tools) <= 900); + Assert.NotNull(saved); + Assert.Equal(2, saved.Revision); + Assert.Equal(990, saved.UsageLedger.Stats.ForCause(GameSessionUsageCause.Assistant).TotalTokens); } [Fact] @@ -1674,7 +2683,8 @@ public async Task RuntimeCompactsAgainAfterALargeToolResultBeforeTheNextModelTur TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => { compacted = true; - return new ValueTask("tool turn summary:" + removed.Count); + return new ValueTask( + new GameTranscriptSummaryResult("tool turn summary:" + removed.Count)); }), }); @@ -1727,7 +2737,8 @@ await store.SaveAsync( TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => { compacted = true; - return new ValueTask("summary:" + removed.Count); + return new ValueTask( + new GameTranscriptSummaryResult("summary:" + removed.Count)); }), }); @@ -2307,6 +3318,9 @@ private static AgentMessage Assistant(string text) => stopReason: ModelStopReason.Stop, usage: new ModelUsage()); + private static string MessageText(AgentMessage message) => + string.Join("\n", message.Content.OfType().Select(content => content.Text)); + private static async Task> CollectAsync(IAsyncEnumerable stream) { var result = new List(); @@ -2501,6 +3515,83 @@ public async ValueTask SaveAsync( } } + private sealed class AppliedButReportedConflictSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + private int _reportedConflict; + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => _inner.LoadAsync(key, cancellationToken); + + public async ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + var saved = await _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + if (saved.Saved && Interlocked.Exchange(ref _reportedConflict, 1) == 0) + { + return new GameSessionSaveResult(saved: false, saved.Current); + } + + return saved; + } + } + + private sealed class AppliedButReportedConflictWrapper : IGameSessionStore + { + private readonly IGameSessionStore _inner; + private int _reportedConflict; + + public AppliedButReportedConflictWrapper(IGameSessionStore inner) + { + _inner = inner; + } + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => _inner.LoadAsync(key, cancellationToken); + + public async ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + var saved = await _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + if (saved.Saved && Interlocked.Exchange(ref _reportedConflict, 1) == 0) + { + return new GameSessionSaveResult(saved: false, saved.Current); + } + + return saved; + } + } + + private sealed class AppliedButReportedConflictOnSecondSaveSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + private int _saveCalls; + + public int SaveCalls => Volatile.Read(ref _saveCalls); + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => _inner.LoadAsync(key, cancellationToken); + + public async ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + var call = Interlocked.Increment(ref _saveCalls); + var saved = await _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + return call == 2 && saved.Saved + ? new GameSessionSaveResult(saved: false, saved.Current) + : saved; + } + } + private sealed class CorruptingSavedSessionStore : IGameSessionStore { public ValueTask LoadAsync( @@ -2633,6 +3724,91 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class RequestTooLargeThenResponseProvider : IModelProvider + { + private int _calls; + + public int CallCount => Volatile.Read(ref _calls); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + var call = Interlocked.Increment(ref _calls); + await Task.Yield(); + if (call == 1) + { + throw new ModelProviderException( + "The request body is too large.", + isTransient: false, + statusCode: 413); + } + + yield return ModelStreamEvent.Terminal(Text("recovered")); + } + } + + private sealed class MeaningfulOutputThenOverflowProvider : IModelProvider + { + private int _calls; + + public int CallCount => Volatile.Read(ref _calls); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _calls); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.TextStarted, + new ModelResponse( + new AgentContent[] { new TextContent(string.Empty) }, + ModelStopReason.Pending)); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.TextDelta, + new ModelResponse( + new AgentContent[] { new TextContent("already visible") }, + ModelStopReason.Pending), + delta: "already visible"); + await Task.Yield(); + throw new ModelProviderException( + "maximum context length exceeded", + isTransient: false, + statusCode: 400); + } + } + + private sealed class RateLimitedProvider : IModelProvider + { + private int _calls; + + public int CallCount => Volatile.Read(ref _calls); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _calls); + await Task.Yield(); + throw new ModelProviderException( + "rate limit: maximum context length metric unavailable", + isTransient: true, + statusCode: 429); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + } + private sealed class FailureThenTerminalProviderWithFailingCleanup : IModelProvider { private readonly int _failuresBeforeSuccess; @@ -2718,7 +3894,14 @@ public async ValueTask GenerateAsync( if (progress is not null) { await progress( - new GameMediaGenerationProgress("rendering", 0.5, "{\"frame\":1}"), + new GameMediaGenerationProgress( + "rendering", + 0.5, + "{\"frame\":1}", + new ResourceContent( + "data:image/png;base64,cHJldmlldw==", + "image/png", + "preview")), cancellationToken); } @@ -3027,13 +4210,13 @@ public ValueTask SaveAsync( private sealed class NullTranscriptCompactor : IGameTranscriptCompactor { - public ValueTask> CompactAsync( + public ValueTask CompactAsync( GameTranscriptCompactionContext context, CancellationToken cancellationToken) { _ = context; cancellationToken.ThrowIfCancellationRequested(); - return new ValueTask>((IReadOnlyList)null!); + return new ValueTask((GameTranscriptCompactionResult)null!); } } } diff --git a/tests/PublicApiSurface.cs b/tests/PublicApiSurface.cs new file mode 100644 index 0000000..ffc596b --- /dev/null +++ b/tests/PublicApiSurface.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace OpenGameAgent.Testing; + +internal static class PublicApiSurface +{ + public static string Hash(Assembly assembly) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Describe(assembly)))); + + public static string Describe(Assembly assembly) + { + var lines = new List(); + foreach (var type in assembly.GetExportedTypes().OrderBy(TypeName, StringComparer.Ordinal)) + { + var kind = type.IsEnum + ? "enum" + : type.IsInterface + ? "interface" + : typeof(MulticastDelegate).IsAssignableFrom(type.BaseType) + ? "delegate" + : type.IsValueType ? "struct" : "class"; + lines.Add($"{kind} {TypeName(type)} base={TypeName(type.BaseType)} interfaces={string.Join(',', type.GetInterfaces().Select(TypeName).OrderBy(value => value, StringComparer.Ordinal))}"); + + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .OrderBy(field => field.Name, StringComparer.Ordinal)) + { + var constant = field.IsLiteral ? FormatConstant(field.GetRawConstantValue()) : string.Empty; + lines.Add($" field {(field.IsStatic ? "static " : string.Empty)}{TypeName(field.FieldType)} {field.Name}{constant}"); + } + + foreach (var constructor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .OrderBy(DescribeParameters, StringComparer.Ordinal)) + { + lines.Add($" ctor {type.Name}({DescribeParameters(constructor)})"); + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .OrderBy(property => property.Name, StringComparer.Ordinal)) + { + var accessor = property.GetMethod ?? property.SetMethod; + lines.Add($" property {(accessor?.IsStatic == true ? "static " : string.Empty)}{TypeName(property.PropertyType)} {property.Name} get={property.GetMethod is not null} set={property.SetMethod is not null} index=({DescribeParameters(property.GetIndexParameters())})"); + } + + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName) + .OrderBy(method => method.Name, StringComparer.Ordinal) + .ThenBy(DescribeParameters, StringComparer.Ordinal)) + { + lines.Add($" method {(method.IsStatic ? "static " : string.Empty)}{TypeName(method.ReturnType)} {method.Name}`{method.GetGenericArguments().Length}({DescribeParameters(method)})"); + } + + foreach (var eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .OrderBy(eventInfo => eventInfo.Name, StringComparer.Ordinal)) + { + lines.Add($" event {TypeName(eventInfo.EventHandlerType)} {eventInfo.Name}"); + } + } + + return string.Join("\n", lines); + } + + private static string DescribeParameters(MethodBase method) => DescribeParameters(method.GetParameters()); + + private static string DescribeParameters(IEnumerable parameters) => + string.Join(",", parameters.Select(parameter => + $"{(parameter.IsOut ? "out " : parameter.ParameterType.IsByRef ? "ref " : string.Empty)}{TypeName(parameter.ParameterType.IsByRef ? parameter.ParameterType.GetElementType() : parameter.ParameterType)} {parameter.Name}{(parameter.HasDefaultValue ? "=" + FormatConstant(parameter.DefaultValue) : string.Empty)}")); + + private static string TypeName(Type? type) + { + if (type is null) + { + return "-"; + } + + if (type.IsArray) + { + return TypeName(type.GetElementType()) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; + } + + if (type.IsGenericParameter) + { + return "!" + type.GenericParameterPosition + ":" + type.Name; + } + + if (!type.IsGenericType) + { + return type.FullName ?? type.Name; + } + + var definition = type.GetGenericTypeDefinition(); + var name = (definition.FullName ?? definition.Name).Split('`')[0]; + return name + "<" + string.Join(",", type.GetGenericArguments().Select(TypeName)) + ">"; + } + + private static string FormatConstant(object? value) => value switch + { + null => "null", + string text => "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"", + char character => "'" + character + "'", + bool boolean => boolean ? "true" : "false", + _ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + }; +} diff --git a/tools/New-ReleaseBundle.ps1 b/tools/New-ReleaseBundle.ps1 index de8393b..9691205 100644 --- a/tools/New-ReleaseBundle.ps1 +++ b/tools/New-ReleaseBundle.ps1 @@ -9,11 +9,13 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') { - throw 'Version must be a semantic version.' -} +. (Join-Path $PSScriptRoot 'Release.Common.ps1') + +$versionInfo = Get-ReleaseVersionInfo -Version $Version $repositoryRoot = Split-Path -Parent $PSScriptRoot +$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot) +Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages if ([string]::IsNullOrWhiteSpace($ArtifactsDirectory)) { $ArtifactsDirectory = Join-Path $repositoryRoot 'artifacts' } @@ -58,19 +60,9 @@ function New-DirectoryArchive { $true) } -$expectedPackages = @( - 'OpenGameAgent.Kernel', - 'OpenGameAgent', - 'OpenGameAgent.Persistence', - 'OpenGameAgent.Providers.OpenAICompatible', - 'OpenGameAgent.Providers.MediaHttp', - 'OpenGameAgent.Client', - 'OpenGameAgent.Extensions', - 'OpenGameAgent.Models', - 'OpenGameAgent.Connectors.Mcp' -) $nugetRoot = Join-Path $artifactsRoot 'nuget' -foreach ($packageId in $expectedPackages) { +foreach ($package in $packages) { + $packageId = [string]$package.id $name = "$packageId.$Version.nupkg" $source = Join-Path $nugetRoot $name if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { @@ -91,27 +83,37 @@ if (-not $serverStage.StartsWith($outputRoot + [IO.Path]::DirectorySeparatorChar throw 'Server staging path is unsafe.' } New-Item -ItemType Directory -Path $serverStage | Out-Null -$serverFiles = @( +$serverMetadataFiles = @( 'appsettings.json', - 'OpenGameAgent.Kernel.dll', - 'OpenGameAgent.dll', - 'OpenGameAgent.Persistence.dll', - 'OpenGameAgent.Providers.OpenAICompatible.dll', 'OpenGameAgent.Server.deps.json', - 'OpenGameAgent.Server.dll', 'OpenGameAgent.Server.runtimeconfig.json' ) -foreach ($relative in $serverFiles) { +$serverDeps = Join-Path $serverSource 'OpenGameAgent.Server.deps.json' +$runtimeAssets = @(Resolve-PortableServerRuntimeAssets -PublishDirectory $serverSource -DepsFile $serverDeps) +foreach ($relative in $serverMetadataFiles) { $source = Join-Path $serverSource $relative if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Portable server file '$relative' is missing." } Copy-Item -LiteralPath $source -Destination (Join-Path $serverStage $relative) } +foreach ($runtimeAsset in $runtimeAssets) { + $destination = [IO.Path]::GetFullPath((Join-Path $serverStage $runtimeAsset.Destination)) + if (-not $destination.StartsWith($serverStage + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Portable runtime destination '$($runtimeAsset.Destination)' is unsafe." + } + $destinationDirectory = Split-Path -Parent $destination + if (-not (Test-Path -LiteralPath $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + Copy-Item -LiteralPath $runtimeAsset.Source -Destination $destination +} Copy-Item -LiteralPath (Join-Path $repositoryRoot 'LICENSE') -Destination (Join-Path $serverStage 'LICENSE') Copy-Item -LiteralPath (Join-Path $repositoryRoot 'docs\deployment-and-security.md') -Destination (Join-Path $serverStage 'README.md') -New-DirectoryArchive -Source $serverStage -Destination (Join-Path $outputRoot "$serverBundleName.zip") +$serverArchive = Join-Path $outputRoot "$serverBundleName.zip" +New-DirectoryArchive -Source $serverStage -Destination $serverArchive Remove-Item -LiteralPath $serverStage -Recurse -Force +Test-PortableServerArchive -Archive $serverArchive -EntryDirectoryName $serverBundleName $changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw $escapedVersion = [regex]::Escape($Version) @@ -132,7 +134,7 @@ $releaseNotes = @( '', 'Use the versioned Godot or Unity archive below for engine integration. The portable server archive runs with `dotnet OpenGameAgent.Server.dll` on a .NET 8 host.', '', - 'This is an alpha release. Public APIs can change before 1.0.' + (Get-ReleaseStabilityNotice -VersionInfo $versionInfo) ) -join [Environment]::NewLine $releaseNotes | Set-Content -LiteralPath (Join-Path $outputRoot 'RELEASE_NOTES.md') -Encoding utf8NoBOM diff --git a/tools/Pack-NuGet.ps1 b/tools/Pack-NuGet.ps1 index 6b775f0..e002b18 100644 --- a/tools/Pack-NuGet.ps1 +++ b/tools/Pack-NuGet.ps1 @@ -7,31 +7,23 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'Release.Common.ps1') + $repositoryRoot = Split-Path -Parent $PSScriptRoot $outputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $OutputDirectory)) New-Item -ItemType Directory -Path $outputPath -Force | Out-Null -if (-not [string]::IsNullOrWhiteSpace($PackageVersion) -and - $PackageVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$') { - throw 'PackageVersion must be valid SemVer.' +if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { + $null = Get-ReleaseVersionInfo -Version $PackageVersion } -$projects = @( - 'src/OpenGameAgent.Kernel/OpenGameAgent.Kernel.csproj', - 'src/OpenGameAgent/OpenGameAgent.csproj', - 'src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj', - 'src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj', - 'src/OpenGameAgent.Providers.MediaHttp/OpenGameAgent.Providers.MediaHttp.csproj', - 'src/OpenGameAgent.Client/OpenGameAgent.Client.csproj', - 'src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj', - 'src/OpenGameAgent.Models/OpenGameAgent.Models.csproj', - 'src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj' -) +$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot) +Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages -foreach ($project in $projects) { +foreach ($package in $packages) { $arguments = @( 'pack', - (Join-Path $repositoryRoot $project), + $package.FullProjectPath, '-c', $Configuration, '--no-build', '--no-restore', @@ -44,7 +36,7 @@ foreach ($project in $projects) { & dotnet @arguments if ($LASTEXITCODE -ne 0) { - throw "Packing failed for '$project'." + throw "Packing failed for '$($package.project)'." } } diff --git a/tools/Publish-NuGet.ps1 b/tools/Publish-NuGet.ps1 new file mode 100644 index 0000000..fb5b4b8 --- /dev/null +++ b/tools/Publish-NuGet.ps1 @@ -0,0 +1,265 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Version, + [Parameter(Mandatory = $true)] + [string] $ApiKey, + [string] $PackagesDirectory = 'release-assets', + [string] $Source = 'https://api.nuget.org/v3/index.json', + [string] $FlatContainerBaseUri = 'https://api.nuget.org/v3-flatcontainer/', + [ValidateRange(60, 1800)] + [int] $ValidationTimeoutSeconds = 1200, + [ValidateRange(1, 60)] + [int] $ValidationPollSeconds = 10, + [ValidateRange(300, 7200)] + [int] $OverallValidationTimeoutSeconds = 3600 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'Release.Common.ps1') + +if ([string]::IsNullOrWhiteSpace($ApiKey)) { + throw 'A NuGet API key is required.' +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$versionInfo = Get-ReleaseVersionInfo -Version $Version +$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot) +Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages +$packageLayers = @(Get-ReleasePackageLayers -Packages $packages) + +$packageRoot = if ([IO.Path]::IsPathRooted($PackagesDirectory)) { + [IO.Path]::GetFullPath($PackagesDirectory) +} +else { + [IO.Path]::GetFullPath((Join-Path $repositoryRoot $PackagesDirectory)) +} +if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { + throw "NuGet package directory '$packageRoot' does not exist." +} + +$expectedNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($package in $packages) { + $null = $expectedNames.Add("$($package.id).$Version.nupkg") +} +$actualPackages = @(Get-ChildItem -LiteralPath $packageRoot -Filter '*.nupkg' -File) +if ($actualPackages.Count -ne $expectedNames.Count) { + throw "Expected $($expectedNames.Count) NuGet packages but found $($actualPackages.Count)." +} +foreach ($actualPackage in $actualPackages) { + if (-not $expectedNames.Contains($actualPackage.Name)) { + throw "Unexpected NuGet package '$($actualPackage.Name)'." + } +} + +$baseUri = [Uri]$FlatContainerBaseUri +if (-not $baseUri.IsAbsoluteUri -or $baseUri.Scheme -ne 'https') { + throw 'NuGet package availability checks require an absolute HTTPS base URI.' +} +$sourceUri = [Uri]$Source +if (-not $sourceUri.IsAbsoluteUri -or $sourceUri.Scheme -ne 'https') { + throw 'NuGet publishing requires an absolute HTTPS source URI.' +} +if (-not [string]::Equals($sourceUri.AbsoluteUri, 'https://api.nuget.org/v3/index.json', [StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals($baseUri.AbsoluteUri, 'https://api.nuget.org/v3-flatcontainer/', [StringComparison]::OrdinalIgnoreCase)) { + throw 'This release publisher only supports the paired NuGet.org service and availability endpoints.' +} + +function Test-PublishedNuGetPackage { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [Net.Http.HttpClient] $Client, + [Parameter(Mandatory = $true)] + [object] $Package, + [Parameter(Mandatory = $true)] + [string] $LocalPackagePath, + [Parameter(Mandatory = $true)] + [string] $TemporaryDirectory, + [switch] $AllowTransientUnavailable + ) + + $packageId = ([string]$Package.id).ToLowerInvariant() + $remotePackageUri = [Uri]::new( + $baseUri, + "$packageId/$($versionInfo.FlatContainerVersion)/$packageId.$($versionInfo.FlatContainerVersion).nupkg") + $remotePackagePath = Join-Path $TemporaryDirectory ($packageId + '.' + [Guid]::NewGuid().ToString('N') + '.nupkg') + try { + try { + $remoteResponse = $Client.GetAsync( + $remotePackageUri, + [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + } + catch [Net.Http.HttpRequestException] { + if ($AllowTransientUnavailable) { + return $false + } + throw + } + catch [Threading.Tasks.TaskCanceledException] { + if ($AllowTransientUnavailable) { + return $false + } + throw + } + try { + if ($remoteResponse.StatusCode -eq [Net.HttpStatusCode]::NotFound) { + return $false + } + if ($AllowTransientUnavailable -and + ($remoteResponse.StatusCode -eq [Net.HttpStatusCode]::RequestTimeout -or + [int]$remoteResponse.StatusCode -eq 429 -or + [int]$remoteResponse.StatusCode -ge 500)) { + return $false + } + if (-not $remoteResponse.IsSuccessStatusCode) { + throw "Published NuGet package download for '$($Package.id)' returned HTTP $([int]$remoteResponse.StatusCode)." + } + if ($null -ne $remoteResponse.Content.Headers.ContentLength -and + $remoteResponse.Content.Headers.ContentLength -gt 314572800) { + throw "Published NuGet package '$($Package.id)' exceeds the release size bound." + } + + $inputStream = $remoteResponse.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + try { + $outputStream = [IO.File]::Open( + $remotePackagePath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None) + try { + $buffer = [byte[]]::new(81920) + [long] $totalBytes = 0 + while (($read = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $totalBytes += $read + if ($totalBytes -gt 314572800) { + throw "Published NuGet package '$($Package.id)' exceeds the release size bound." + } + $outputStream.Write($buffer, 0, $read) + } + } + finally { + $outputStream.Dispose() + } + } + finally { + $inputStream.Dispose() + } + } + finally { + $remoteResponse.Dispose() + } + + $verificationOutput = @(& dotnet nuget verify --all $remotePackagePath --verbosity quiet 2>&1) + if ($LASTEXITCODE -ne 0) { + $summary = ($verificationOutput -join [Environment]::NewLine).Trim() + if ($summary.Length -gt 2000) { + $summary = $summary.Substring(0, 2000) + } + throw "Published NuGet package '$($Package.id)' failed signature verification.$([Environment]::NewLine)$summary" + } + $publishedHash = Get-NuGetRepositorySignedContentHash ` + -PackagePath $remotePackagePath ` + -ExpectedServiceIndexUri $sourceUri + try { + Assert-UnsignedNuGetPackageContentHash ` + -PackagePath $LocalPackagePath ` + -Algorithm $publishedHash.Algorithm ` + -ExpectedHash $publishedHash.HashBytes + } + catch { + throw "NuGet package '$($Package.id)' version '$Version' already exists with different content." + } + + return $true + } + finally { + if (Test-Path -LiteralPath $remotePackagePath) { + Remove-Item -LiteralPath $remotePackagePath -Force + } + } +} + +$httpClient = [Net.Http.HttpClient]::new() +$httpClient.Timeout = [TimeSpan]::FromSeconds(120) +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ('opengameagent-nuget-publish-' + [Guid]::NewGuid().ToString('N')) +$publishPlan = [Collections.Generic.List[object]]::new() +try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + foreach ($package in $packages) { + $packagePath = Join-Path $packageRoot "$($package.id).$Version.nupkg" + if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { + throw "NuGet package '$packagePath' is missing." + } + + $alreadyPublished = Test-PublishedNuGetPackage ` + -Client $httpClient ` + -Package $package ` + -LocalPackagePath $packagePath ` + -TemporaryDirectory $temporaryRoot + $publishPlan.Add([pscustomobject]@{ + Package = $package + PackagePath = $packagePath + AlreadyPublished = $alreadyPublished + }) + } + + $planById = @{} + foreach ($planItem in $publishPlan) { + $planById[[string]$planItem.Package.id] = $planItem + } + $overallDeadline = [DateTimeOffset]::UtcNow.AddSeconds($OverallValidationTimeoutSeconds) + foreach ($layer in $packageLayers) { + if ([DateTimeOffset]::UtcNow -ge $overallDeadline) { + throw "Overall NuGet validation timeout expired before dependency layer $($layer.Depth) could be published." + } + $pending = [Collections.Generic.List[object]]::new() + foreach ($package in $layer.Packages) { + $planItem = $planById[[string]$package.id] + if ($planItem.AlreadyPublished) { + Write-Output "NuGet package '$($planItem.Package.id)' version '$Version' is already published with verified identical content." + continue + } + + & dotnet nuget push $planItem.PackagePath --api-key $ApiKey --source $Source --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Publishing NuGet package '$($planItem.Package.id)' failed." + } + $pending.Add($planItem) + } + + $layerDeadline = [DateTimeOffset]::UtcNow.AddSeconds($ValidationTimeoutSeconds) + if ($overallDeadline -lt $layerDeadline) { + $layerDeadline = $overallDeadline + } + while ($pending.Count -gt 0 -and [DateTimeOffset]::UtcNow -lt $layerDeadline) { + for ($index = $pending.Count - 1; $index -ge 0; $index--) { + $planItem = $pending[$index] + if (Test-PublishedNuGetPackage ` + -Client $httpClient ` + -Package $planItem.Package ` + -LocalPackagePath $planItem.PackagePath ` + -TemporaryDirectory $temporaryRoot ` + -AllowTransientUnavailable) { + Write-Output "NuGet package '$($planItem.Package.id)' passed remote validation." + $pending.RemoveAt($index) + } + } + if ($pending.Count -gt 0 -and [DateTimeOffset]::UtcNow -lt $layerDeadline) { + Start-Sleep -Seconds $ValidationPollSeconds + } + } + if ($pending.Count -gt 0) { + $pendingIds = ($pending | ForEach-Object { [string]$_.Package.id }) -join ', ' + throw "Timed out waiting for NuGet validation and indexing at dependency layer $($layer.Depth): $pendingIds." + } + } +} +finally { + $httpClient.Dispose() + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} diff --git a/tools/Release.Common.ps1 b/tools/Release.Common.ps1 new file mode 100644 index 0000000..0c39720 --- /dev/null +++ b/tools/Release.Common.ps1 @@ -0,0 +1,697 @@ +function Get-ReleaseVersionInfo { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Version + ) + + if ([string]::IsNullOrWhiteSpace($Version) -or $Version.Length -gt 64) { + throw 'Release version must contain between 1 and 64 characters.' + } + + if ($Version.Contains('+')) { + throw 'Release versions cannot contain build metadata because NuGet removes it from package identity.' + } + + $match = [regex]::Match( + $Version, + '^(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$') + if (-not $match.Success) { + throw 'Release version must be a canonical SemVer version without build metadata.' + } + + [int] $major = 0 + [int] $minor = 0 + [int] $patch = 0 + foreach ($numericComponent in @( + [pscustomobject]@{ Name = 'major'; Value = $match.Groups['major'].Value; Target = [ref]$major }, + [pscustomobject]@{ Name = 'minor'; Value = $match.Groups['minor'].Value; Target = [ref]$minor }, + [pscustomobject]@{ Name = 'patch'; Value = $match.Groups['patch'].Value; Target = [ref]$patch } + )) { + if (-not [int]::TryParse( + $numericComponent.Value, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + $numericComponent.Target)) { + throw "Release version $($numericComponent.Name) component exceeds NuGet's Int32 range." + } + } + + $prerelease = $match.Groups['prerelease'].Value + if (-not [string]::IsNullOrEmpty($prerelease)) { + foreach ($identifier in $prerelease.Split('.')) { + if ($identifier -match '^[0-9]+$' -and $identifier.Length -gt 1 -and $identifier[0] -eq '0') { + throw 'Numeric prerelease identifiers cannot contain leading zeroes.' + } + } + } + + [pscustomobject]@{ + Version = $Version + Major = $major + Minor = $minor + Patch = $patch + IsPrerelease = -not [string]::IsNullOrEmpty($prerelease) + FlatContainerVersion = $Version.ToLowerInvariant() + } +} + +function Get-ReleaseStabilityNotice { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object] $VersionInfo + ) + + if ($VersionInfo.IsPrerelease) { + return 'This is a pre-release. Public APIs may change before the final release.' + } + if ([int]$VersionInfo.Major -eq 0) { + return 'This is a stable-version release. Before 1.0, minor versions can still change public APIs.' + } + + return 'This is a stable release governed by semantic-versioning compatibility guarantees.' +} + +function Get-NuGetRepositorySignedContentHash { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $PackagePath, + [Uri] $ExpectedServiceIndexUri = 'https://api.nuget.org/v3/index.json' + ) + + $resolvedPath = [IO.Path]::GetFullPath($PackagePath) + if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { + throw "Published NuGet package '$resolvedPath' does not exist." + } + $packageLength = (Get-Item -LiteralPath $resolvedPath).Length + if ($packageLength -le 0 -or $packageLength -gt 314572800) { + throw 'Published NuGet package size is outside the accepted release bounds.' + } + if ($null -eq $ExpectedServiceIndexUri -or + -not $ExpectedServiceIndexUri.IsAbsoluteUri -or + $ExpectedServiceIndexUri.Scheme -ne 'https') { + throw 'An absolute HTTPS NuGet service index URI is required.' + } + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.Security.Cryptography.Pkcs + Add-Type -AssemblyName System.Formats.Asn1 + + $archive = [IO.Compression.ZipFile]::OpenRead($resolvedPath) + try { + $signatureEntries = @($archive.Entries | Where-Object { $_.FullName -ceq '.signature.p7s' }) + $ambiguousSignatureEntries = @($archive.Entries | Where-Object { + $_.FullName -ieq '.signature.p7s' -and $_.FullName -cne '.signature.p7s' + }) + if ($signatureEntries.Count -ne 1 -or $ambiguousSignatureEntries.Count -ne 0) { + throw 'Published NuGet package must contain exactly one canonical signature entry.' + } + + $signatureEntry = $signatureEntries[0] + if ($signatureEntry.Length -le 0 -or + $signatureEntry.Length -gt 1048576 -or + $signatureEntry.CompressedLength -ne $signatureEntry.Length) { + throw 'Published NuGet package signature entry is invalid or exceeds its size bound.' + } + + $signatureStream = $signatureEntry.Open() + try { + $signatureBuffer = [IO.MemoryStream]::new([int]$signatureEntry.Length) + try { + $signatureStream.CopyTo($signatureBuffer) + $signatureBytes = $signatureBuffer.ToArray() + } + finally { + $signatureBuffer.Dispose() + } + } + finally { + $signatureStream.Dispose() + } + } + finally { + $archive.Dispose() + } + + try { + $signedCms = [Security.Cryptography.Pkcs.SignedCms]::new() + $signedCms.Decode($signatureBytes) + $signedCms.CheckSignature($true) + } + catch { + throw 'Published NuGet package signature is malformed or cryptographically invalid.' + } + if ($signedCms.SignerInfos.Count -ne 1) { + throw 'Published NuGet package must contain exactly one primary signer.' + } + + $primarySigner = $signedCms.SignerInfos[0] + $repositoryAttributes = @($primarySigner.SignedAttributes | Where-Object { + $_.Oid.Value -eq '1.3.6.1.4.1.311.84.2.1.1.1' + }) + if ($repositoryAttributes.Count -ne 1 -or $repositoryAttributes[0].Values.Count -ne 1) { + throw 'Published NuGet package does not contain a unique primary repository signature.' + } + try { + $attributeReader = [Formats.Asn1.AsnReader]::new( + $repositoryAttributes[0].Values[0].RawData, + [Formats.Asn1.AsnEncodingRules]::DER) + $repositoryServiceIndex = $attributeReader.ReadCharacterString( + [Formats.Asn1.UniversalTagNumber]::IA5String) + $attributeReader.ThrowIfNotEmpty() + } + catch { + throw 'Published NuGet package repository signature metadata is malformed.' + } + if (-not [string]::Equals( + $repositoryServiceIndex, + $ExpectedServiceIndexUri.AbsoluteUri, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Published NuGet package was signed by an unexpected repository '$repositoryServiceIndex'." + } + + try { + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $signatureContent = $strictUtf8.GetString($signedCms.ContentInfo.Content) + } + catch { + throw 'Published NuGet package signature content is not valid UTF-8.' + } + if ($signatureContent.Contains("`0") -or + $signatureContent -match "`r(?!`n)" -or + $signatureContent -notmatch '\AVersion:1(?:\r?\n){2}') { + throw 'Published NuGet package signature content has an invalid properties document.' + } + + $hashMatches = [regex]::Matches( + $signatureContent, + '(?m)^(?2\.16\.840\.1\.101\.3\.4\.2\.[123])-Hash:(?[A-Za-z0-9+/]+={0,2})\r?$') + if ($hashMatches.Count -ne 1) { + throw 'Published NuGet package signature must contain exactly one supported content hash.' + } + + $hashMetadata = switch ($hashMatches[0].Groups['oid'].Value) { + '2.16.840.1.101.3.4.2.1' { [pscustomobject]@{ Algorithm = 'SHA256'; Length = 32 } } + '2.16.840.1.101.3.4.2.2' { [pscustomobject]@{ Algorithm = 'SHA384'; Length = 48 } } + '2.16.840.1.101.3.4.2.3' { [pscustomobject]@{ Algorithm = 'SHA512'; Length = 64 } } + default { throw 'Published NuGet package uses an unsupported content hash algorithm.' } + } + try { + $hashBytes = [Convert]::FromBase64String($hashMatches[0].Groups['value'].Value) + } + catch { + throw 'Published NuGet package signature content hash is malformed.' + } + if ($hashBytes.Length -ne $hashMetadata.Length) { + throw 'Published NuGet package signature content hash has an invalid length.' + } + + return [pscustomobject]@{ + Algorithm = $hashMetadata.Algorithm + HashBytes = $hashBytes + HashBase64 = [Convert]::ToBase64String($hashBytes) + } +} + +function Test-UnsignedNuGetPackageContentHash { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $PackagePath, + [Parameter(Mandatory = $true)] + [ValidateSet('SHA256', 'SHA384', 'SHA512')] + [string] $Algorithm, + [Parameter(Mandatory = $true)] + [byte[]] $ExpectedHash + ) + + $resolvedPath = [IO.Path]::GetFullPath($PackagePath) + if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { + throw "Local NuGet package '$resolvedPath' does not exist." + } + $packageLength = (Get-Item -LiteralPath $resolvedPath).Length + if ($packageLength -le 0 -or $packageLength -gt 314572800) { + throw 'Local NuGet package size is outside the accepted release bounds.' + } + + Add-Type -AssemblyName System.IO.Compression + $archive = [IO.Compression.ZipFile]::OpenRead($resolvedPath) + try { + $signatureEntries = @($archive.Entries | Where-Object { $_.FullName -ieq '.signature.p7s' }) + if ($signatureEntries.Count -ne 0) { + throw 'Release retry verification currently requires the local NuGet package to be unsigned.' + } + } + finally { + $archive.Dispose() + } + + $expectedLength = switch ($Algorithm) { + 'SHA256' { 32 } + 'SHA384' { 48 } + 'SHA512' { 64 } + } + if ($null -eq $ExpectedHash -or $ExpectedHash.Length -ne $expectedLength) { + throw "Expected $Algorithm content hash has an invalid length." + } + + $localHashHex = (Get-FileHash -LiteralPath $resolvedPath -Algorithm $Algorithm).Hash + $localHash = [Convert]::FromHexString($localHashHex) + return [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($localHash, $ExpectedHash) +} + +function Assert-UnsignedNuGetPackageContentHash { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $PackagePath, + [Parameter(Mandatory = $true)] + [ValidateSet('SHA256', 'SHA384', 'SHA512')] + [string] $Algorithm, + [Parameter(Mandatory = $true)] + [byte[]] $ExpectedHash + ) + + if (-not (Test-UnsignedNuGetPackageContentHash ` + -PackagePath $PackagePath ` + -Algorithm $Algorithm ` + -ExpectedHash $ExpectedHash)) { + throw 'Published NuGet package content does not match the local release package.' + } +} + +function Get-ReleasePackageManifest { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $RepositoryRoot + ) + + $root = [IO.Path]::GetFullPath($RepositoryRoot) + $manifestPath = Join-Path $root 'tools/release-packages.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "Release package manifest '$manifestPath' does not exist." + } + + $document = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($document.schemaVersion -ne 1) { + throw 'Release package manifest has an unsupported schema version.' + } + + $packages = @($document.packages) + if ($packages.Count -eq 0) { + throw 'Release package manifest cannot be empty.' + } + + $ids = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $paths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($package in $packages) { + $id = [string]$package.id + $project = [string]$package.project + if ($id -notmatch '^[A-Za-z0-9_.-]{1,100}$') { + throw "Release package id '$id' is invalid." + } + if (-not $ids.Add($id)) { + throw "Release package id '$id' is duplicated." + } + if ([string]::IsNullOrWhiteSpace($project) -or [IO.Path]::IsPathRooted($project)) { + throw "Release project path '$project' must be repository-relative." + } + + $portableProject = $project.Replace('/', [IO.Path]::DirectorySeparatorChar).Replace('\', [IO.Path]::DirectorySeparatorChar) + $fullProject = [IO.Path]::GetFullPath((Join-Path $root $portableProject)) + if (-not $fullProject.StartsWith($root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Release project path '$project' escapes the repository." + } + if (-not (Test-Path -LiteralPath $fullProject -PathType Leaf)) { + throw "Release project '$project' does not exist." + } + if (-not $paths.Add($fullProject)) { + throw "Release project '$project' is duplicated." + } + + Add-Member -InputObject $package -NotePropertyName FullProjectPath -NotePropertyValue $fullProject -Force + } + + return $packages +} + +function Assert-ReleasePackageManifestGraph { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $RepositoryRoot, + [Parameter(Mandatory = $true)] + [object[]] $Packages + ) + + $root = [IO.Path]::GetFullPath($RepositoryRoot) + $sourceRoot = Join-Path $root 'src' + $packableProjects = @{} + foreach ($projectFile in Get-ChildItem -LiteralPath $sourceRoot -Recurse -Filter '*.csproj' -File) { + [xml]$projectXml = Get-Content -LiteralPath $projectFile.FullName + $isPackableValues = @($projectXml.SelectNodes('/Project/PropertyGroup/IsPackable')) + if ($isPackableValues.Count -gt 0 -and [string]$isPackableValues[-1].InnerText -eq 'false') { + continue + } + + $packageIdValues = @($projectXml.SelectNodes('/Project/PropertyGroup/PackageId')) + $packageId = if ($packageIdValues.Count -gt 0) { [string]$packageIdValues[-1].InnerText } else { $projectFile.BaseName } + $packableProjects[[IO.Path]::GetFullPath($projectFile.FullName)] = $packageId + } + + $manifestPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $manifestIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $positionByPath = @{} + for ($index = 0; $index -lt $Packages.Count; $index++) { + $package = $Packages[$index] + $path = [IO.Path]::GetFullPath([string]$package.FullProjectPath) + $null = $manifestPaths.Add($path) + $null = $manifestIds.Add([string]$package.id) + $positionByPath[$path] = $index + + if (-not $packableProjects.ContainsKey($path)) { + throw "Release manifest project '$($package.project)' is not a packable source project." + } + if (-not [string]::Equals($packableProjects[$path], [string]$package.id, [StringComparison]::Ordinal)) { + throw "Release package id '$($package.id)' does not match project package id '$($packableProjects[$path])'." + } + } + + foreach ($projectPath in $packableProjects.Keys) { + if (-not $manifestPaths.Contains($projectPath)) { + throw "Packable project '$projectPath' is missing from the release manifest." + } + } + if ($manifestPaths.Count -ne $packableProjects.Count -or $manifestIds.Count -ne $packableProjects.Count) { + throw 'Release package manifest does not map one-to-one to packable source projects.' + } + + foreach ($package in $Packages) { + $projectPath = [IO.Path]::GetFullPath([string]$package.FullProjectPath) + [xml]$projectXml = Get-Content -LiteralPath $projectPath + foreach ($reference in @($projectXml.SelectNodes('/Project/ItemGroup/ProjectReference'))) { + if ([string]::IsNullOrWhiteSpace([string]$reference.Include)) { + continue + } + + $portableReference = ([string]$reference.Include).Replace('/', [IO.Path]::DirectorySeparatorChar).Replace('\', [IO.Path]::DirectorySeparatorChar) + $dependencyPath = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $projectPath) $portableReference)) + if (-not $positionByPath.ContainsKey($dependencyPath)) { + continue + } + if ($positionByPath[$dependencyPath] -ge $positionByPath[$projectPath]) { + throw "Release package '$($package.id)' appears before dependency '$($packableProjects[$dependencyPath])'." + } + } + } +} + +function Get-ReleasePackageLayers { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object[]] $Packages + ) + + $packageByPath = @{} + foreach ($package in $Packages) { + $packageByPath[[IO.Path]::GetFullPath([string]$package.FullProjectPath)] = $package + } + + $depthByPath = @{} + $groups = [Collections.Generic.SortedDictionary[int,Collections.Generic.List[object]]]::new() + foreach ($package in $Packages) { + $projectPath = [IO.Path]::GetFullPath([string]$package.FullProjectPath) + [xml]$projectXml = Get-Content -LiteralPath $projectPath + [int] $depth = 0 + foreach ($reference in @($projectXml.SelectNodes('/Project/ItemGroup/ProjectReference'))) { + if ([string]::IsNullOrWhiteSpace([string]$reference.Include)) { + continue + } + $portableReference = ([string]$reference.Include).Replace('/', [IO.Path]::DirectorySeparatorChar).Replace('\', [IO.Path]::DirectorySeparatorChar) + $dependencyPath = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $projectPath) $portableReference)) + if (-not $packageByPath.ContainsKey($dependencyPath)) { + continue + } + if (-not $depthByPath.ContainsKey($dependencyPath)) { + throw "Release package '$($package.id)' appears before one of its dependencies." + } + $depth = [Math]::Max($depth, [int]$depthByPath[$dependencyPath] + 1) + } + $depthByPath[$projectPath] = $depth + if (-not $groups.ContainsKey($depth)) { + $groups.Add($depth, [Collections.Generic.List[object]]::new()) + } + $groups[$depth].Add($package) + } + + return @($groups.GetEnumerator() | ForEach-Object { + [pscustomobject]@{ + Depth = [int]$_.Key + Packages = [object[]]$_.Value.ToArray() + } + }) +} + +function Resolve-PortableServerRuntimeAssets { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $PublishDirectory, + [Parameter(Mandatory = $true)] + [string] $DepsFile + ) + + $publishRoot = [IO.Path]::GetFullPath($PublishDirectory) + $depsPath = [IO.Path]::GetFullPath($DepsFile) + if (-not (Test-Path -LiteralPath $publishRoot -PathType Container)) { + throw "Server publish directory '$publishRoot' does not exist." + } + if (-not (Test-Path -LiteralPath $depsPath -PathType Leaf)) { + throw "Server dependency manifest '$depsPath' does not exist." + } + + $document = Get-Content -LiteralPath $depsPath -Raw | ConvertFrom-Json + $targets = @($document.targets.PSObject.Properties) + if ($targets.Count -ne 1) { + throw 'Portable server dependency manifest must contain exactly one runtime target.' + } + + $declaredAssets = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($library in $targets[0].Value.PSObject.Properties) { + foreach ($sectionName in @('runtime', 'native', 'resources', 'runtimeTargets')) { + $section = $library.Value.PSObject.Properties[$sectionName] + if ($null -eq $section) { + continue + } + foreach ($assetName in $section.Value.PSObject.Properties.Name) { + if ([IO.Path]::GetFileName([string]$assetName) -eq '_._') { + continue + } + $null = $declaredAssets.Add([string]$assetName) + } + } + } + if ($declaredAssets.Count -eq 0) { + throw 'Portable server dependency manifest contains no runtime assets.' + } + + $resolvedByDestination = @{} + foreach ($declaredAsset in $declaredAssets) { + if ([IO.Path]::IsPathRooted($declaredAsset)) { + throw "Runtime asset '$declaredAsset' must be relative." + } + $segments = $declaredAsset.Replace('\', '/').Split('/', [StringSplitOptions]::RemoveEmptyEntries) + if ($segments -contains '..') { + throw "Runtime asset '$declaredAsset' contains a parent traversal." + } + + $portableAsset = $declaredAsset.Replace('/', [IO.Path]::DirectorySeparatorChar).Replace('\', [IO.Path]::DirectorySeparatorChar) + $candidates = [Collections.Generic.List[string]]::new() + $candidates.Add([IO.Path]::GetFullPath((Join-Path $publishRoot $portableAsset))) + $leaf = [IO.Path]::GetFileName($portableAsset) + $leafCandidate = [IO.Path]::GetFullPath((Join-Path $publishRoot $leaf)) + if (-not $candidates.Contains($leafCandidate)) { + $candidates.Add($leafCandidate) + } + + $matches = @($candidates | Where-Object { + $_.StartsWith($publishRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -and + (Test-Path -LiteralPath $_ -PathType Leaf) + } | Select-Object -Unique) + if ($matches.Count -eq 0) { + $matches = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File | Where-Object Name -eq $leaf | Select-Object -ExpandProperty FullName) + } + if ($matches.Count -ne 1) { + throw "Runtime asset '$declaredAsset' resolved to $($matches.Count) published files." + } + + $sourcePath = [IO.Path]::GetFullPath($matches[0]) + if (-not $sourcePath.StartsWith($publishRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Runtime asset '$declaredAsset' escapes the publish directory." + } + $destination = [IO.Path]::GetRelativePath($publishRoot, $sourcePath) + if ($resolvedByDestination.ContainsKey($destination) -and + -not [string]::Equals($resolvedByDestination[$destination], $sourcePath, [StringComparison]::OrdinalIgnoreCase)) { + throw "Multiple runtime assets map to '$destination'." + } + $resolvedByDestination[$destination] = $sourcePath + } + + $resolvedSources = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($source in $resolvedByDestination.Values) { + $null = $resolvedSources.Add([IO.Path]::GetFullPath($source)) + } + $publishedRuntimeFiles = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File | Where-Object { + $_.Extension -in @('.dll', '.so', '.dylib') + }) + foreach ($publishedRuntimeFile in $publishedRuntimeFiles) { + if (-not $resolvedSources.Contains([IO.Path]::GetFullPath($publishedRuntimeFile.FullName))) { + throw "Published runtime file '$($publishedRuntimeFile.FullName)' is absent from the dependency manifest." + } + } + + return @($resolvedByDestination.GetEnumerator() | Sort-Object Key | ForEach-Object { + [pscustomobject]@{ + Source = [string]$_.Value + Destination = [string]$_.Key + } + }) +} + +function Test-PortableServerArchive { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Archive, + [Parameter(Mandatory = $true)] + [string] $EntryDirectoryName, + [int] $TimeoutSeconds = 20 + ) + + if ($TimeoutSeconds -lt 5 -or $TimeoutSeconds -gt 60) { + throw 'Portable server smoke timeout must be between 5 and 60 seconds.' + } + if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) { + throw "Portable server archive '$Archive' does not exist." + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ('opengameagent-server-smoke-' + [Guid]::NewGuid().ToString('N')) + $process = $null + $client = $null + $failure = $null + $standardOutput = '' + $standardError = '' + try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory([IO.Path]::GetFullPath($Archive), $temporaryRoot) + $serverRoot = Join-Path $temporaryRoot $EntryDirectoryName + $serverAssembly = Join-Path $serverRoot 'OpenGameAgent.Server.dll' + if (-not (Test-Path -LiteralPath $serverAssembly -PathType Leaf)) { + throw 'Portable server archive does not contain the server assembly at its documented path.' + } + + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } + finally { + $listener.Stop() + } + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command dotnet -ErrorAction Stop).Source + $startInfo.WorkingDirectory = $serverRoot + $startInfo.ArgumentList.Add('OpenGameAgent.Server.dll') + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Environment['ASPNETCORE_URLS'] = "http://127.0.0.1:$port" + $startInfo.Environment['DOTNET_NOLOGO'] = '1' + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw 'Portable server process did not start.' + } + + $client = [Net.Http.HttpClient]::new() + $client.Timeout = [TimeSpan]::FromSeconds(1) + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) + $healthy = $false + while ([DateTimeOffset]::UtcNow -lt $deadline) { + if ($process.HasExited) { + $failure = "Portable server exited before becoming healthy with code $($process.ExitCode)." + break + } + try { + $response = $client.GetAsync("http://127.0.0.1:$port/healthz").GetAwaiter().GetResult() + try { + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if ($response.IsSuccessStatusCode -and $body -match '"status"\s*:\s*"healthy"') { + $healthy = $true + break + } + } + finally { + $response.Dispose() + } + } + catch [Net.Http.HttpRequestException] { + } + catch [Threading.Tasks.TaskCanceledException] { + } + Start-Sleep -Milliseconds 100 + } + if (-not $healthy -and $null -eq $failure) { + $failure = "Portable server did not become healthy within $TimeoutSeconds seconds." + } + } + catch { + $failure = $_.Exception.Message + } + finally { + if ($null -ne $client) { + $client.Dispose() + } + if ($null -ne $process) { + try { + if (-not $process.HasExited) { + $process.Kill($true) + } + if (-not $process.WaitForExit(5000)) { + if ($null -eq $failure) { + $failure = 'Portable server process did not exit during cleanup.' + } + } + else { + $standardOutput = $process.StandardOutput.ReadToEnd() + $standardError = $process.StandardError.ReadToEnd() + } + } + catch { + if ($null -eq $failure) { + $failure = 'Portable server process cleanup failed: ' + $_.Exception.Message + } + } + finally { + $process.Dispose() + } + } + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } + } + + if ($null -ne $failure) { + $logs = ($standardOutput + [Environment]::NewLine + $standardError).Trim() + if ($logs.Length -gt 4000) { + $logs = $logs.Substring(0, 4000) + } + throw ($failure + $(if ($logs.Length -gt 0) { [Environment]::NewLine + $logs } else { '' })) + } +} diff --git a/tools/Test-FrozenReleaseAssets.ps1 b/tools/Test-FrozenReleaseAssets.ps1 new file mode 100644 index 0000000..7740156 --- /dev/null +++ b/tools/Test-FrozenReleaseAssets.ps1 @@ -0,0 +1,171 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $CandidateDirectory, + [Parameter(Mandatory = $true)] + [string] $FrozenDirectory +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-ArchivePayloadMap { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $ArchivePath, + [switch] $IgnoreNuGetContainerMetadata + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $result = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) + $archive = [IO.Compression.ZipFile]::OpenRead($ArchivePath) + try { + if ($archive.Entries.Count -gt 10000) { + throw "Archive '$ArchivePath' contains too many entries." + } + [long] $totalLength = 0 + [int] $ignoredRelationships = 0 + [int] $ignoredCoreProperties = 0 + foreach ($entry in $archive.Entries) { + $name = [string]$entry.FullName + if ([string]::IsNullOrWhiteSpace($name) -or + $name.Contains('\') -or + $name.Contains("`0") -or + [IO.Path]::IsPathRooted($name) -or + $name.Split('/', [StringSplitOptions]::RemoveEmptyEntries) -contains '..') { + throw "Archive '$ArchivePath' contains unsafe entry '$name'." + } + if ($name.EndsWith('/', [StringComparison]::Ordinal)) { + continue + } + if ($entry.Length -lt 0 -or $entry.Length -gt 314572800) { + throw "Archive '$ArchivePath' entry '$name' exceeds its size bound." + } + $totalLength += $entry.Length + if ($totalLength -gt 1073741824) { + throw "Archive '$ArchivePath' exceeds its total uncompressed size bound." + } + + if ($IgnoreNuGetContainerMetadata -and $name -ieq '_rels/.rels') { + $ignoredRelationships++ + continue + } + if ($IgnoreNuGetContainerMetadata -and + $name -imatch '^package/services/metadata/core-properties/[0-9a-f-]+\.psmdcp$') { + $ignoredCoreProperties++ + continue + } + if ($name -ieq '.signature.p7s') { + throw "Frozen release package '$ArchivePath' must be unsigned." + } + if ($result.ContainsKey($name)) { + throw "Archive '$ArchivePath' contains duplicate entry '$name'." + } + + $stream = $entry.Open() + try { + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $hash = [Convert]::ToHexString($sha256.ComputeHash($stream)) + } + finally { + $sha256.Dispose() + } + } + finally { + $stream.Dispose() + } + $result.Add($name, "$($entry.Length):$hash") + } + if ($IgnoreNuGetContainerMetadata -and + ($ignoredRelationships -ne 1 -or $ignoredCoreProperties -ne 1)) { + throw "NuGet package '$ArchivePath' does not contain exactly one expected OPC metadata pair." + } + } + finally { + $archive.Dispose() + } + + return $result +} + +function Assert-ArchivePayloadEqual { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $CandidatePath, + [Parameter(Mandatory = $true)] + [string] $FrozenPath, + [switch] $IgnoreNuGetContainerMetadata + ) + + $candidate = Get-ArchivePayloadMap ` + -ArchivePath $CandidatePath ` + -IgnoreNuGetContainerMetadata:$IgnoreNuGetContainerMetadata + $frozen = Get-ArchivePayloadMap ` + -ArchivePath $FrozenPath ` + -IgnoreNuGetContainerMetadata:$IgnoreNuGetContainerMetadata + if ($candidate.Count -ne $frozen.Count) { + throw "Frozen asset '$([IO.Path]::GetFileName($FrozenPath))' has a different payload entry count." + } + foreach ($entryName in $candidate.Keys) { + if (-not $frozen.ContainsKey($entryName) -or + -not [string]::Equals($candidate[$entryName], $frozen[$entryName], [StringComparison]::Ordinal)) { + throw "Frozen asset '$([IO.Path]::GetFileName($FrozenPath))' differs at payload entry '$entryName'." + } + } +} + +$candidateRoot = [IO.Path]::GetFullPath($CandidateDirectory) +$frozenRoot = [IO.Path]::GetFullPath($FrozenDirectory) +foreach ($root in @($candidateRoot, $frozenRoot)) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + throw "Release asset directory '$root' does not exist." + } +} + +$candidateAssets = @{} +foreach ($asset in Get-ChildItem -LiteralPath $candidateRoot -File) { + if ($asset.Name -in @('RELEASE_NOTES.md', 'SHA256SUMS.txt')) { + continue + } + $candidateAssets[$asset.Name] = $asset.FullName +} +$frozenAssets = @{} +foreach ($asset in Get-ChildItem -LiteralPath $frozenRoot -File) { + if ($asset.Name -in @('RELEASE_NOTES.md', 'SHA256SUMS.txt')) { + continue + } + $frozenAssets[$asset.Name] = $asset.FullName +} +if ($candidateAssets.Count -ne $frozenAssets.Count) { + throw 'Frozen release asset count differs from the trusted build candidate.' +} + +foreach ($assetName in $candidateAssets.Keys) { + if (-not $frozenAssets.ContainsKey($assetName)) { + throw "Frozen release omits trusted asset '$assetName'." + } + $extension = [IO.Path]::GetExtension($assetName) + if ($extension -ieq '.nupkg') { + Assert-ArchivePayloadEqual ` + -CandidatePath $candidateAssets[$assetName] ` + -FrozenPath $frozenAssets[$assetName] ` + -IgnoreNuGetContainerMetadata + } + elseif ($extension -ieq '.zip') { + Assert-ArchivePayloadEqual ` + -CandidatePath $candidateAssets[$assetName] ` + -FrozenPath $frozenAssets[$assetName] + } + else { + $candidateHash = (Get-FileHash -LiteralPath $candidateAssets[$assetName] -Algorithm SHA256).Hash + $frozenHash = (Get-FileHash -LiteralPath $frozenAssets[$assetName] -Algorithm SHA256).Hash + if (-not [string]::Equals($candidateHash, $frozenHash, [StringComparison]::Ordinal)) { + throw "Frozen release asset '$assetName' differs from the trusted build candidate." + } + } +} + +Write-Output "Frozen release payload matches all $($candidateAssets.Count) trusted build assets." diff --git a/tools/Test-NuGetPackages.ps1 b/tools/Test-NuGetPackages.ps1 new file mode 100644 index 0000000..f0220f2 --- /dev/null +++ b/tools/Test-NuGetPackages.ps1 @@ -0,0 +1,254 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $PackageVersion, + [string] $PackagesDirectory = 'artifacts/nuget', + [string] $ExpectedRepositoryCommit, + [switch] $SkipConsumerRestore +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'Release.Common.ps1') + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$versionInfo = Get-ReleaseVersionInfo -Version $PackageVersion +$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot) +Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages + +$packageRoot = if ([IO.Path]::IsPathRooted($PackagesDirectory)) { + [IO.Path]::GetFullPath($PackagesDirectory) +} +else { + [IO.Path]::GetFullPath((Join-Path $repositoryRoot $PackagesDirectory)) +} +if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { + throw "NuGet package directory '$packageRoot' does not exist." +} + +$expectedFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($package in $packages) { + $null = $expectedFiles.Add("$($package.id).$PackageVersion.nupkg") +} +$actualFiles = @(Get-ChildItem -LiteralPath $packageRoot -Filter "*.$PackageVersion.nupkg" -File) +if ($actualFiles.Count -ne $expectedFiles.Count) { + throw "Expected $($expectedFiles.Count) versioned packages but found $($actualFiles.Count)." +} +foreach ($actualFile in $actualFiles) { + if (-not $expectedFiles.Contains($actualFile.Name)) { + throw "Unexpected release package '$($actualFile.Name)'." + } +} +foreach ($expectedFile in $expectedFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $packageRoot $expectedFile) -PathType Leaf)) { + throw "Expected release package '$expectedFile' is missing." + } +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$idByProject = @{} +foreach ($package in $packages) { + $idByProject[[IO.Path]::GetFullPath([string]$package.FullProjectPath)] = [string]$package.id +} +foreach ($package in $packages) { + $expectedDependencies = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase) + [xml]$projectXml = Get-Content -LiteralPath $package.FullProjectPath + $targetFrameworkNodes = @($projectXml.SelectNodes('/Project/PropertyGroup/TargetFramework')) + if ($targetFrameworkNodes.Count -ne 1 -or [string]::IsNullOrWhiteSpace($targetFrameworkNodes[0].InnerText)) { + throw "Release project '$($package.id)' must declare exactly one target framework." + } + $targetFramework = [string]$targetFrameworkNodes[0].InnerText + $assemblyNameNodes = @($projectXml.SelectNodes('/Project/PropertyGroup/AssemblyName')) + $assemblyName = if ($assemblyNameNodes.Count -gt 0) { + [string]$assemblyNameNodes[-1].InnerText + } + else { + [IO.Path]::GetFileNameWithoutExtension([string]$package.FullProjectPath) + } + foreach ($reference in @($projectXml.SelectNodes('/Project/ItemGroup/ProjectReference'))) { + $portableReference = ([string]$reference.Include).Replace('/', [IO.Path]::DirectorySeparatorChar).Replace('\', [IO.Path]::DirectorySeparatorChar) + $dependencyProject = [IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $package.FullProjectPath) $portableReference)) + if ($idByProject.ContainsKey($dependencyProject)) { + $expectedDependencies.Add($idByProject[$dependencyProject], "[$PackageVersion]") + } + } + foreach ($reference in @($projectXml.SelectNodes('/Project/ItemGroup/PackageReference'))) { + $dependencyId = [string]$reference.Include + $dependencyVersion = [string]$reference.Version + if ([string]::IsNullOrWhiteSpace($dependencyId) -or [string]::IsNullOrWhiteSpace($dependencyVersion)) { + throw "Release project '$($package.id)' contains an unresolved package dependency." + } + if ($expectedDependencies.ContainsKey($dependencyId)) { + throw "Release project '$($package.id)' contains duplicate dependency '$dependencyId'." + } + $expectedDependencies.Add($dependencyId, $dependencyVersion) + } + + $packagePath = Join-Path $packageRoot "$($package.id).$PackageVersion.nupkg" + $archive = [IO.Compression.ZipFile]::OpenRead($packagePath) + try { + $nuspecEntries = @($archive.Entries | Where-Object FullName -like '*.nuspec') + if ($nuspecEntries.Count -ne 1) { + throw "Release package '$($package.id)' contains $($nuspecEntries.Count) nuspec manifests." + } + $expectedAssemblyPath = "lib/$targetFramework/$assemblyName.dll" + $compileAssets = @($archive.Entries | Where-Object { + $_.FullName -ieq $expectedAssemblyPath + }) + if ($compileAssets.Count -ne 1 -or $compileAssets[0].Length -le 0) { + throw "Release package '$($package.id)' does not contain non-empty '$expectedAssemblyPath'." + } + $reader = [IO.StreamReader]::new($nuspecEntries[0].Open()) + try { + [xml]$nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + } + finally { + $archive.Dispose() + } + + $metadataNode = $nuspec.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]') + $idNode = $metadataNode.SelectSingleNode('./*[local-name()="id"]') + $versionNode = $metadataNode.SelectSingleNode('./*[local-name()="version"]') + if ($null -eq $idNode -or -not [string]::Equals($idNode.InnerText, [string]$package.id, [StringComparison]::Ordinal)) { + throw "Release package '$($package.id)' has an unexpected package id." + } + if ($null -eq $versionNode -or -not [string]::Equals($versionNode.InnerText, $PackageVersion, [StringComparison]::Ordinal)) { + throw "Release package '$($package.id)' has an unexpected package version." + } + if (-not [string]::IsNullOrWhiteSpace($ExpectedRepositoryCommit)) { + if ($ExpectedRepositoryCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Expected repository commit must be a full SHA-1 object id.' + } + $repositoryNode = $metadataNode.SelectSingleNode('./*[local-name()="repository"]') + if ($null -eq $repositoryNode -or + -not [string]::Equals([string]$repositoryNode.commit, $ExpectedRepositoryCommit, [StringComparison]::OrdinalIgnoreCase)) { + throw "Release package '$($package.id)' is not bound to repository commit '$ExpectedRepositoryCommit'." + } + } + + $actualDependencies = @{} + foreach ($dependencyNode in @($metadataNode.SelectNodes('.//*[local-name()="dependency"]'))) { + $dependencyId = [string]$dependencyNode.id + $dependencyVersion = [string]$dependencyNode.version + if ($actualDependencies.ContainsKey($dependencyId) -and + -not [string]::Equals($actualDependencies[$dependencyId], $dependencyVersion, [StringComparison]::Ordinal)) { + throw "Release package '$($package.id)' contains conflicting versions for '$dependencyId'." + } + $actualDependencies[$dependencyId] = $dependencyVersion + } + if ($actualDependencies.Count -ne $expectedDependencies.Count) { + throw "Release package '$($package.id)' dependency count does not match its project references and package references." + } + foreach ($expectedDependency in $expectedDependencies.Keys) { + if (-not $actualDependencies.ContainsKey($expectedDependency)) { + throw "Release package '$($package.id)' omits dependency '$expectedDependency'." + } + if (-not [string]::Equals( + $actualDependencies[$expectedDependency], + $expectedDependencies[$expectedDependency], + [StringComparison]::Ordinal)) { + throw "Release package '$($package.id)' has an unexpected version for dependency '$expectedDependency'." + } + } +} + +if ($SkipConsumerRestore) { + Write-Output "Statically verified all $($packages.Count) release packages." + return +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ('opengameagent-nuget-smoke-' + [Guid]::NewGuid().ToString('N')) +try { + $consumerRoot = Join-Path $temporaryRoot 'consumer' + $packageCache = Join-Path $temporaryRoot 'packages' + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + + & dotnet new classlib --framework netstandard2.1 --name ReleaseConsumer --output $consumerRoot --no-restore + if ($LASTEXITCODE -ne 0) { + throw 'Creating the clean NuGet consumer failed.' + } + $consumerProject = Join-Path $consumerRoot 'ReleaseConsumer.csproj' + foreach ($package in $packages) { + & dotnet add $consumerProject package $package.id --version $PackageVersion --source $packageRoot --no-restore + if ($LASTEXITCODE -ne 0) { + throw "Adding release package '$($package.id)' to the clean consumer failed." + } + } + + $escapedPackageRoot = [Security.SecurityElement]::Escape($packageRoot) + $nugetConfig = Join-Path $temporaryRoot 'NuGet.Config' + $configuration = @" + + + + + + + + +"@ + [IO.File]::WriteAllText($nugetConfig, $configuration, [Text.UTF8Encoding]::new($false)) + + & dotnet restore $consumerProject --configfile $nugetConfig --packages $packageCache --no-cache --nologo + if ($LASTEXITCODE -ne 0) { + throw 'Restoring the clean NuGet consumer failed.' + } + & dotnet build $consumerProject -c Release --no-restore --nologo + if ($LASTEXITCODE -ne 0) { + throw 'Building the clean NuGet consumer failed.' + } + + $assetsPath = Join-Path $consumerRoot 'obj/project.assets.json' + $assets = Get-Content -LiteralPath $assetsPath -Raw | ConvertFrom-Json + $libraries = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($libraryName in $assets.libraries.PSObject.Properties.Name) { + $null = $libraries.Add([string]$libraryName) + } + + foreach ($package in $packages) { + $identity = "$($package.id)/$PackageVersion" + if (-not $libraries.Contains($identity)) { + throw "Clean consumer assets do not contain '$identity'." + } + + $metadataPath = Join-Path $packageCache (Join-Path $package.id.ToLowerInvariant() (Join-Path $versionInfo.FlatContainerVersion '.nupkg.metadata')) + if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + throw "Restore metadata for '$identity' is missing." + } + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + $metadataSource = [IO.Path]::GetFullPath([string]$metadata.source) + if (-not [string]::Equals($metadataSource.TrimEnd([IO.Path]::DirectorySeparatorChar), $packageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { + throw "Clean consumer restored '$identity' from '$metadataSource' instead of the release package directory." + } + + $packagePath = Join-Path $packageRoot "$($package.id).$PackageVersion.nupkg" + $stream = [IO.File]::OpenRead($packagePath) + try { + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + $contentHash = [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + } + } + finally { + $stream.Dispose() + } + if (-not [string]::Equals($contentHash, [string]$metadata.contentHash, [StringComparison]::Ordinal)) { + throw "Clean consumer content hash for '$identity' does not match the release package." + } + } +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} + +Write-Output "Clean consumer restored and built all $($packages.Count) release packages." diff --git a/tools/Test-ReleaseScripts.ps1 b/tools/Test-ReleaseScripts.ps1 new file mode 100644 index 0000000..0505f98 --- /dev/null +++ b/tools/Test-ReleaseScripts.ps1 @@ -0,0 +1,206 @@ +[CmdletBinding()] +param( + [string] $Version = '0.3.0-alpha.1' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'Release.Common.ps1') + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$versionInfo = Get-ReleaseVersionInfo -Version $Version +if ($versionInfo.Version -ne $Version) { + throw 'Release version validation changed the supplied version.' +} + +$prereleaseProbe = Get-ReleaseVersionInfo -Version '1.2.3-rc.1' +if (-not $prereleaseProbe.IsPrerelease) { + throw 'Prerelease version classification failed.' +} +$stableProbe = Get-ReleaseVersionInfo -Version '1.2.3' +if ($stableProbe.IsPrerelease) { + throw 'Stable version classification failed.' +} +if ($stableProbe.Major -ne 1 -or $stableProbe.Minor -ne 2 -or $stableProbe.Patch -ne 3) { + throw 'Release version numeric component parsing failed.' +} +$maximumNumericProbe = Get-ReleaseVersionInfo -Version '2147483647.2147483647.2147483647' +if ($maximumNumericProbe.Major -ne [int]::MaxValue) { + throw 'Release version Int32 boundary parsing failed.' +} +if ((Get-ReleaseStabilityNotice -VersionInfo (Get-ReleaseVersionInfo -Version '0.9.0')) -notmatch 'Before 1\.0') { + throw 'Pre-1.0 stable release notice classification failed.' +} +if ((Get-ReleaseStabilityNotice -VersionInfo $stableProbe) -match 'Before 1\.0') { + throw 'Post-1.0 stable release notice classification failed.' +} +if ((Get-ReleaseStabilityNotice -VersionInfo $prereleaseProbe) -notmatch 'pre-release') { + throw 'Prerelease notice classification failed.' +} + +foreach ($invalidVersion in @( + '1.2.3+build.7', + '01.2.3', + '1.02.3', + '1.2.03', + '1.2.3-rc.01', + '../1.2.3', + '2147483648.0.0', + '0.2147483648.0', + '0.0.2147483648' +)) { + $rejected = $false + try { + $null = Get-ReleaseVersionInfo -Version $invalidVersion + } + catch { + $rejected = $true + } + if (-not $rejected) { + throw "Invalid release version '$invalidVersion' was accepted." + } +} + +$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot) +Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages +$packageLayers = @(Get-ReleasePackageLayers -Packages $packages) +$layeredPackages = @($packageLayers | ForEach-Object { $_.Packages }) +if ($packageLayers.Count -eq 0 -or $layeredPackages.Count -ne $packages.Count) { + throw 'Release package dependency layering does not cover the manifest exactly once.' +} +for ($layerIndex = 0; $layerIndex -lt $packageLayers.Count; $layerIndex++) { + if ($packageLayers[$layerIndex].Depth -ne $layerIndex -or $packageLayers[$layerIndex].Packages.Count -eq 0) { + throw 'Release package dependency layers are not contiguous and non-empty.' + } +} + +Add-Type -AssemblyName System.IO.Compression +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ('opengameagent-release-script-test-' + [Guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $localPackage = Join-Path $temporaryRoot 'local.nupkg' + $changedPackage = Join-Path $temporaryRoot 'changed.nupkg' + foreach ($packageFixture in @( + [pscustomobject]@{ Path = $localPackage; Content = 'original' }, + [pscustomobject]@{ Path = $changedPackage; Content = 'changed' } + )) { + $archive = [IO.Compression.ZipFile]::Open( + $packageFixture.Path, + [IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('lib/net8.0/Test.dll') + $entryStream = $entry.Open() + try { + $writer = [IO.StreamWriter]::new($entryStream, [Text.UTF8Encoding]::new($false)) + try { + $writer.Write($packageFixture.Content) + } + finally { + $writer.Dispose() + } + } + finally { + $entryStream.Dispose() + } + } + finally { + $archive.Dispose() + } + } + + $localHash = [Convert]::FromHexString( + (Get-FileHash -LiteralPath $localPackage -Algorithm SHA256).Hash) + Assert-UnsignedNuGetPackageContentHash ` + -PackagePath $localPackage ` + -Algorithm SHA256 ` + -ExpectedHash $localHash + + $differentContentRejected = $false + try { + Assert-UnsignedNuGetPackageContentHash ` + -PackagePath $changedPackage ` + -Algorithm SHA256 ` + -ExpectedHash $localHash + } + catch { + $differentContentRejected = $true + } + if (-not $differentContentRejected) { + throw 'A partial NuGet publish retry accepted different package content.' + } + + Add-Type -AssemblyName System.Security.Cryptography.Pkcs + Add-Type -AssemblyName System.Formats.Asn1 + $properties = [Text.Encoding]::UTF8.GetBytes( + "Version:1`n`n2.16.840.1.101.3.4.2.1-Hash:$([Convert]::ToBase64String($localHash))`n") + $contentInfo = [Security.Cryptography.Pkcs.ContentInfo]::new($properties) + $signedCms = [Security.Cryptography.Pkcs.SignedCms]::new($contentInfo, $false) + $rsa = [Security.Cryptography.RSA]::Create(2048) + try { + $certificateRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=OpenGameAgent release fixture', + $rsa, + [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $certificate = $certificateRequest.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-1), + [DateTimeOffset]::UtcNow.AddMinutes(10)) + try { + $signer = [Security.Cryptography.Pkcs.CmsSigner]::new($certificate) + $attributeWriter = [Formats.Asn1.AsnWriter]::new([Formats.Asn1.AsnEncodingRules]::DER) + $attributeWriter.WriteCharacterString( + [Formats.Asn1.UniversalTagNumber]::IA5String, + 'https://api.nuget.org/v3/index.json') + $attributeOid = [Security.Cryptography.Oid]::new('1.3.6.1.4.1.311.84.2.1.1.1') + $attributeValues = [Security.Cryptography.AsnEncodedDataCollection]::new() + $null = $attributeValues.Add([Security.Cryptography.AsnEncodedData]::new( + $attributeOid, + $attributeWriter.Encode())) + $null = $signer.SignedAttributes.Add( + [Security.Cryptography.CryptographicAttributeObject]::new($attributeOid, $attributeValues)) + $signedCms.ComputeSignature($signer) + } + finally { + $certificate.Dispose() + } + } + finally { + $rsa.Dispose() + } + + $signedPackage = Join-Path $temporaryRoot 'repository-signed.nupkg' + $signedArchive = [IO.Compression.ZipFile]::Open( + $signedPackage, + [IO.Compression.ZipArchiveMode]::Create) + try { + $signatureEntry = $signedArchive.CreateEntry( + '.signature.p7s', + [IO.Compression.CompressionLevel]::NoCompression) + $signatureStream = $signatureEntry.Open() + try { + $signatureBytes = $signedCms.Encode() + $signatureStream.Write($signatureBytes, 0, $signatureBytes.Length) + } + finally { + $signatureStream.Dispose() + } + } + finally { + $signedArchive.Dispose() + } + $publishedHash = Get-NuGetRepositorySignedContentHash -PackagePath $signedPackage + if ($publishedHash.Algorithm -ne 'SHA256' -or + -not [Security.Cryptography.CryptographicOperations]::FixedTimeEquals( + $publishedHash.HashBytes, + $localHash)) { + throw 'Repository-signed NuGet content hash parsing failed.' + } +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} + +Write-Output "Release script checks passed for $($packages.Count) topologically ordered packages." diff --git a/tools/release-packages.json b/tools/release-packages.json new file mode 100644 index 0000000..458aaae --- /dev/null +++ b/tools/release-packages.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "id": "OpenGameAgent.Kernel", + "project": "src/OpenGameAgent.Kernel/OpenGameAgent.Kernel.csproj" + }, + { + "id": "OpenGameAgent.ProviderTransport", + "project": "src/OpenGameAgent.ProviderTransport/OpenGameAgent.ProviderTransport.csproj" + }, + { + "id": "OpenGameAgent", + "project": "src/OpenGameAgent/OpenGameAgent.csproj" + }, + { + "id": "OpenGameAgent.Models", + "project": "src/OpenGameAgent.Models/OpenGameAgent.Models.csproj" + }, + { + "id": "OpenGameAgent.Providers.Anthropic", + "project": "src/OpenGameAgent.Providers.Anthropic/OpenGameAgent.Providers.Anthropic.csproj" + }, + { + "id": "OpenGameAgent.Providers.Bedrock", + "project": "src/OpenGameAgent.Providers.Bedrock/OpenGameAgent.Providers.Bedrock.csproj" + }, + { + "id": "OpenGameAgent.Providers.Google", + "project": "src/OpenGameAgent.Providers.Google/OpenGameAgent.Providers.Google.csproj" + }, + { + "id": "OpenGameAgent.Providers.MessageGateway", + "project": "src/OpenGameAgent.Providers.MessageGateway/OpenGameAgent.Providers.MessageGateway.csproj" + }, + { + "id": "OpenGameAgent.Providers.Mistral", + "project": "src/OpenGameAgent.Providers.Mistral/OpenGameAgent.Providers.Mistral.csproj" + }, + { + "id": "OpenGameAgent.Providers.OpenAI", + "project": "src/OpenGameAgent.Providers.OpenAI/OpenGameAgent.Providers.OpenAI.csproj" + }, + { + "id": "OpenGameAgent.Providers.OpenAICompatible", + "project": "src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj" + }, + { + "id": "OpenGameAgent.Providers.Remote", + "project": "src/OpenGameAgent.Providers.Remote/OpenGameAgent.Providers.Remote.csproj" + }, + { + "id": "OpenGameAgent.Client", + "project": "src/OpenGameAgent.Client/OpenGameAgent.Client.csproj" + }, + { + "id": "OpenGameAgent.Extensions", + "project": "src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj" + }, + { + "id": "OpenGameAgent.Media", + "project": "src/OpenGameAgent.Media/OpenGameAgent.Media.csproj" + }, + { + "id": "OpenGameAgent.Models.BuiltIn", + "project": "src/OpenGameAgent.Models.BuiltIn/OpenGameAgent.Models.BuiltIn.csproj" + }, + { + "id": "OpenGameAgent.Providers.MediaHttp", + "project": "src/OpenGameAgent.Providers.MediaHttp/OpenGameAgent.Providers.MediaHttp.csproj" + }, + { + "id": "OpenGameAgent.Connectors.Mcp", + "project": "src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj" + }, + { + "id": "OpenGameAgent.Models.Auth.BuiltIn", + "project": "src/OpenGameAgent.Models.Auth.BuiltIn/OpenGameAgent.Models.Auth.BuiltIn.csproj" + }, + { + "id": "OpenGameAgent.Persistence", + "project": "src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj" + }, + { + "id": "OpenGameAgent.Providers.OpenRouter", + "project": "src/OpenGameAgent.Providers.OpenRouter/OpenGameAgent.Providers.OpenRouter.csproj" + } + ] +} diff --git a/tools/update-model-directory.ps1 b/tools/update-model-directory.ps1 new file mode 100644 index 0000000..4c0326c --- /dev/null +++ b/tools/update-model-directory.ps1 @@ -0,0 +1,426 @@ +param( + [string]$OutputPath = "$PSScriptRoot/../src/OpenGameAgent.Models/Data/model-directory.json", + [string]$CatalogUrl = "https://models.dev/api.json" +) + +$ErrorActionPreference = "Stop" +$source = Invoke-RestMethod -Uri $CatalogUrl -TimeoutSec 60 + +$providerIds = @( + "amazon-bedrock", "anthropic", "baseten", "cerebras", "cloudflare-ai-gateway", + "cloudflare-workers-ai", "deepseek", "fireworks-ai", "google", "google-vertex", + "groq", "huggingface", "kimi-for-coding", "minimax", "minimax-cn", "mistral", + "moonshotai", "moonshotai-cn", "nvidia", "openai", "openrouter", "togetherai", + "xai", "zai", "zai-coding-plan", "zhipuai", "zhipuai-coding-plan" +) + +function Get-ApiId([string]$providerId, [object]$provider, [string]$modelId) { + switch ($providerId) { + "amazon-bedrock" { return "bedrock-converse-stream" } + "anthropic" { return "anthropic-messages" } + "fireworks-ai" { + if ($modelId -match "glm-5p2|kimi-k3") { return "openai-completions" } + return "anthropic-messages" + } + "kimi-for-coding" { return "anthropic-messages" } + "minimax" { return "anthropic-messages" } + "minimax-cn" { return "anthropic-messages" } + "google" { return "google-generative-ai" } + "google-vertex" { return "google-vertex" } + "mistral" { return "mistral-conversations" } + "openai" { return "openai-responses" } + "xai" { + if ($modelId -eq "grok-4.5") { return "openai-responses" } + return "openai-completions" + } + default { + if ($provider.npm -eq "@ai-sdk/openai") { return "openai-responses" } + return "openai-completions" + } + } +} + +function Get-ModelEndpoint([string]$providerId, [string]$apiId, [string]$providerEndpoint) { + if ($providerId -eq "fireworks-ai") { + if ($apiId -eq "anthropic-messages") { + return "https://api.fireworks.ai/inference" + } + return "https://api.fireworks.ai/inference/v1" + } + return $providerEndpoint +} + +function Get-ModelHeaders([string]$providerId) { + $headers = [ordered]@{} + if ($providerId -eq "nvidia") { $headers["NVCF-POLL-SECONDS"] = "3600" } + if ($providerId -eq "kimi-for-coding") { $headers["User-Agent"] = "KimiCLI/1.5" } + return $headers +} + +function Test-AnthropicAdaptiveModel([string]$modelId) { + return $modelId -match "opus[-.]4[-.](6|7|8)|opus[-.]5|sonnet[-.]4[-.]6|sonnet[-.]5|fable[-.]5" +} + +function Get-ReasoningProfile([string]$providerId, [string]$apiId, [string]$modelId, [object]$model) { + $supported = [ordered]@{} + if (-not $model.reasoning) { + $supported["off"] = $null + } else { + foreach ($level in @("off", "minimal", "low", "medium", "high")) { $supported[$level] = $null } + $efforts = @($model.reasoning_options | Where-Object { $_.type -eq "effort" } | ForEach-Object { $_.values }) + $recognized = @($efforts | Where-Object { $_ -in @("none", "minimal", "low", "medium", "high", "xhigh", "max") }) + if ($recognized.Count -gt 0) { + $supported.Clear() + if ($recognized -contains "none") { $supported["off"] = "none" } + foreach ($level in @("minimal", "low", "medium", "high", "xhigh", "max")) { + if ($recognized -contains $level) { $supported[$level] = $level } + } + } + + $id = $modelId.ToLowerInvariant() + if ($apiId -eq "openai-responses" -and $providerId -eq "openai") { + if ($modelId -in @("gpt-5.1", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna")) { + $supported["off"] = "none" + } + if ($id -match "gpt-5[.](2|3|4|5|6)") { $supported["xhigh"] = "xhigh" } + if ($id -match "gpt-5[.]6") { $supported["max"] = "max" } + if ($modelId -eq "gpt-5.5") { $supported.Remove("minimal") } + if ($modelId.EndsWith("gpt-5.5-pro", [System.StringComparison]::Ordinal)) { + $supported.Remove("off"); $supported.Remove("minimal"); $supported.Remove("low") + } + } + if ($providerId -eq "xai" -and $apiId -eq "openai-responses" -and $modelId -eq "grok-4.5") { + $supported.Remove("off"); $supported.Remove("minimal") + } + if ($apiId -in @("google-generative-ai", "google-vertex")) { + if ($id -match "gemini-3([.][0-9]+)?-pro") { + $supported.Clear(); $supported["low"] = "LOW"; $supported["high"] = "HIGH" + } elseif ($id -match "gemini-3([.][0-9]+)?-flash" -or $id -in @("gemini-flash-latest", "gemini-flash-lite-latest")) { + $supported.Remove("off") + } elseif ($id -match "gemma-?4") { + $supported.Clear(); $supported["minimal"] = "MINIMAL"; $supported["high"] = "HIGH" + } + } + if ($providerId -in @("moonshotai", "moonshotai-cn") -and $modelId -in @("kimi-k2.7-code", "kimi-k2.7-code-highspeed")) { + $supported.Remove("off") + } + if ($providerId -eq "openrouter" -and $id.StartsWith("inception/mercury-2")) { $supported.Remove("off") } + if ($providerId -eq "openrouter" -and $modelId -eq "z-ai/glm-5.2") { $supported["xhigh"] = "xhigh" } + if ($providerId -eq "deepseek" -and $id.Contains("deepseek-v4")) { + $supported.Clear(); $supported["off"] = $null; $supported["high"] = "high"; $supported["max"] = "max" + } + if ($providerId -in @("zai", "zai-coding-plan", "zhipuai", "zhipuai-coding-plan") -and $modelId -eq "glm-5.2") { + $supported.Clear(); $supported["off"] = $null; $supported["low"] = "high"; $supported["medium"] = "high"; $supported["high"] = "high"; $supported["max"] = "max" + } + if ($providerId -eq "baseten" -and $modelId -in @("zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast")) { + $supported.Clear(); $supported["off"] = "none"; $supported["high"] = "high"; $supported["max"] = "max" + } elseif ($providerId -eq "baseten") { + $hasToggle = @($model.reasoning_options | Where-Object { $_.type -eq "toggle" }).Count -gt 0 + if ($hasToggle) { + $supported["off"] = "off" + $supported.Remove("minimal"); $supported.Remove("low"); $supported.Remove("medium") + } + } + if ($providerId -eq "fireworks-ai" -and $modelId -match "glm-5p2") { + $supported.Clear(); $supported["off"] = "none"; $supported["low"] = "high" + $supported["medium"] = "high"; $supported["high"] = "high"; $supported["max"] = "max" + } + if ($providerId -eq "togetherai" -and $model.reasoning) { + if ($modelId -in @("deepseek-ai/DeepSeek-R1", "MiniMaxAI/MiniMax-M2.7")) { + $supported.Remove("off"); $supported.Remove("minimal"); $supported.Remove("low"); $supported.Remove("medium") + } elseif ($modelId -in @("openai/gpt-oss-20b", "openai/gpt-oss-120b")) { + $supported.Remove("off"); $supported.Remove("minimal") + } elseif ($modelId -eq "deepseek-ai/DeepSeek-V4-Pro") { + $supported.Remove("minimal"); $supported.Remove("low"); $supported.Remove("medium") + $supported["high"] = "high" + } else { + $supported.Remove("minimal"); $supported.Remove("low"); $supported.Remove("medium") + } + } + if ($apiId -eq "anthropic-messages" -and (Test-AnthropicAdaptiveModel $modelId)) { + $supported["max"] = "max" + if ($id -match "opus[-.]4[-.](7|8)|opus[-.]5|sonnet[-.]5|fable[-.]5") { $supported["xhigh"] = "xhigh" } + if ($id -match "fable[-.]5") { $supported.Remove("off") } + } + if ($providerId -eq "groq" -and $modelId -eq "qwen/qwen3.6-27b") { + $supported.Remove("minimal"); $supported.Remove("low"); $supported.Remove("medium") + $supported["high"] = "default" + } + } + + if ($supported.Count -eq 0) { $supported["high"] = $null } + $levels = [System.Collections.Generic.List[string]]::new() + $values = [ordered]@{} + foreach ($level in @("off", "minimal", "low", "medium", "high", "xhigh", "max")) { + if ($supported.Contains($level)) { + $levels.Add($level) + if ($null -ne $supported[$level]) { $values[$level] = [string]$supported[$level] } + } + } + return [pscustomobject]@{ Levels = @($levels); Values = $values } +} + +function Get-ModelCompatibility([string]$providerId, [string]$apiId, [string]$endpoint, [string]$modelId, [object]$model) { + $compatibility = [ordered]@{ + supportsTemperature = [bool]($model.temperature ?? $false) + structuredOutput = [bool]($model.structured_output ?? $false) + } + if ($null -ne $model.interleaved) { $compatibility["interleaved"] = $model.interleaved } + + if ($apiId -eq "openai-completions") { + $isZai = $providerId -in @("zai", "zai-coding-plan", "zhipuai", "zhipuai-coding-plan") + $isTogether = $providerId -eq "togetherai" + $isMoonshot = $providerId -in @("moonshotai", "moonshotai-cn") + $isOpenRouter = $providerId -eq "openrouter" + $isWorkers = $providerId -eq "cloudflare-workers-ai" + $isGateway = $providerId -eq "cloudflare-ai-gateway" + $isNvidia = $providerId -eq "nvidia" + $isGrok = $providerId -eq "xai" + $isDeepSeek = $providerId -eq "deepseek" + $isNonStandard = $isNvidia -or $providerId -in @("baseten", "cerebras", "fireworks-ai", "xai") -or $isTogether -or $isDeepSeek -or $isZai -or $isMoonshot -or $isWorkers -or $isGateway + $useMaxTokens = $providerId -eq "baseten" -or $isMoonshot -or $isGateway -or $isTogether -or $isNvidia -or $isZai + $compatibility["supportsStore"] = -not $isNonStandard + $compatibility["supportsDeveloperRole"] = if ($isOpenRouter) { $modelId.StartsWith("anthropic/") -or $modelId.StartsWith("openai/") } else { -not $isNonStandard } + $compatibility["supportsReasoningEffort"] = -not ($isGrok -or $isZai -or $isMoonshot -or $isTogether -or $isGateway -or $isNvidia) + $compatibility["supportsUsageInStreaming"] = $true + $compatibility["supportsFinishReason"] = $true + $compatibility["maxTokensField"] = $useMaxTokens ? "max_tokens" : "max_completion_tokens" + $compatibility["requiresToolResultName"] = $false + $compatibility["requiresAssistantAfterToolResult"] = $false + $compatibility["requiresThinkingAsText"] = $false + $compatibility["requiresReasoningContentOnAssistantMessages"] = $isDeepSeek + $compatibility["thinkingFormat"] = $isDeepSeek ? "deepseek" : ($isZai ? "zai" : ($isTogether -and $model.reasoning ? "together" : ($isOpenRouter ? "openrouter" : "openai"))) + $compatibility["supportsStrictMode"] = -not ($isMoonshot -or $isTogether -or $isGateway -or $isNvidia) + $compatibility["supportsOpenAIGrammarTools"] = $false + $compatibility["sendSessionAffinityHeaders"] = $isWorkers -or $providerId -eq "fireworks-ai" + $compatibility["sessionAffinityFormat"] = $isOpenRouter ? "openrouter" : "openai" + $compatibility["supportsLongCacheRetention"] = -not ($isTogether -or $isWorkers -or $isGateway -or $isNvidia -or $providerId -in @("baseten", "fireworks-ai")) + if ($isOpenRouter -and $modelId -match "^~?anthropic/") { $compatibility["cacheControlFormat"] = "anthropic" } + if ($providerId -eq "huggingface") { $compatibility["supportsDeveloperRole"] = $false } + if ($isZai) { + $compatibility["supportsDeveloperRole"] = $false + $compatibility["supportsReasoningEffort"] = $modelId -eq "glm-5.2" + if ($modelId -notin @("glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v")) { $compatibility["zaiToolStream"] = $true } + } + if ($isMoonshot) { + $compatibility["thinkingFormat"] = $modelId -match "kimi-k3" ? "openai" : "deepseek" + $compatibility["supportsReasoningEffort"] = $modelId -match "kimi-k3" + } + if ($isTogether) { + if ($modelId -in @("openai/gpt-oss-20b", "openai/gpt-oss-120b")) { + $compatibility["thinkingFormat"] = "openai"; $compatibility["supportsReasoningEffort"] = $true + } elseif ($modelId -eq "deepseek-ai/DeepSeek-V4-Pro") { + $compatibility["thinkingFormat"] = "together"; $compatibility["supportsReasoningEffort"] = $true + } + } + if ($providerId -eq "baseten") { + $options = @($model.reasoning_options) + $toggle = @($options | Where-Object type -eq "toggle").Count -gt 0 -or $modelId -in @("zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast") + $effort = @($options | Where-Object type -eq "effort").Count -gt 0 -or $modelId -in @("zai-org/GLM-5.2", "zai-org/GLM-5.2-Fast") + $compatibility["supportsReasoningEffort"] = $effort + if ($toggle) { + $compatibility["thinkingFormat"] = "baseten" + $compatibility["chatTemplateArgs"] = [ordered]@{ enable_thinking = [ordered]@{ '$var' = "thinking.enabled" } } + } + } + if ($providerId -eq "fireworks-ai" -and $modelId -match "kimi-k3") { + $compatibility["requiresReasoningContentOnAssistantMessages"] = $true + $compatibility["thinkingFormat"] = "openai" + $compatibility["deferredToolsMode"] = "kimi" + } + } elseif ($apiId -eq "openai-responses") { + $compatibility["supportsDeveloperRole"] = $true + $compatibility["supportsStrictMode"] = $providerId -eq "openai" + $compatibility["supportsOpenAIGrammarTools"] = $providerId -eq "openai" -and $modelId -match "^gpt-[5-9]" + $toolSearch = $providerId -eq "openai" -and $modelId -in @("gpt-5.4", "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna") + $compatibility["supportsAdditionalTools"] = $toolSearch + $compatibility["supportsToolSearch"] = $toolSearch + $compatibility["supportsExplicitPromptCacheMode"] = $providerId -eq "openai" -and [decimal]($model.cost.cache_write ?? 0) -gt 0 + $compatibility["supportsLongCacheRetention"] = $providerId -ne "xai" + $compatibility["sessionAffinityFormat"] = "openai" + } elseif ($apiId -eq "anthropic-messages") { + $compatibility["supportsEagerToolInputStreaming"] = $providerId -ne "fireworks-ai" + $compatibility["supportsLongCacheRetention"] = $providerId -ne "fireworks-ai" + $compatibility["sendSessionAffinityHeaders"] = $providerId -eq "fireworks-ai" + $compatibility["supportsCacheControlOnTools"] = $providerId -ne "fireworks-ai" + $compatibility["forceAdaptiveThinking"] = $providerId -eq "kimi-for-coding" -or (Test-AnthropicAdaptiveModel $modelId) + $compatibility["allowEmptySignature"] = $providerId -eq "kimi-for-coding" -and $modelId -in @("k3", "kimi-for-coding") + $compatibility["supportsStrictTools"] = $providerId -eq "anthropic" + $toolReferences = $false + if ($providerId -eq "anthropic" -and -not $modelId.Contains("haiku")) { + $match = [regex]::Match($modelId, '^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)') + if ($match.Success) { + $major = [int]$match.Groups[1].Value + $minor = $match.Groups[2].Success -and $match.Groups[2].Value.Length -lt 8 ? [int]$match.Groups[2].Value : 0 + $toolReferences = $major -gt 4 -or ($major -eq 4 -and $minor -ge 5) + } + } + $compatibility["supportsToolReferences"] = $toolReferences + } elseif ($apiId -eq "bedrock-converse-stream") { + $compatibility["supportsStrictMode"] = [bool]($model.structured_output ?? $false) + } elseif ($apiId -in @("google-generative-ai", "google-vertex")) { + $compatibility["useLegacyOpenApiToolSchemas"] = $false + } + return $compatibility +} + +function Get-ProviderEndpoint([string]$providerId, [object]$provider) { + if ($provider.api) { return [string]$provider.api } + switch ($providerId) { + "cerebras" { return "https://api.cerebras.ai/v1" } + "cloudflare-ai-gateway" { return 'https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat' } + "groq" { return "https://api.groq.com/openai/v1" } + "togetherai" { return "https://api.together.ai/v1" } + "xai" { return "https://api.x.ai/v1" } + default { return $null } + } +} + +function Get-InputCapabilities([object]$model) { + $values = [System.Collections.Generic.List[string]]::new() + foreach ($value in @($model.modalities.input)) { + if ($value -in @("text", "image") -and -not $values.Contains($value)) { + $values.Add($value) + } + } + if ($values.Count -eq 0) { $values.Add("text") } + if (-not $values.Contains("structured")) { $values.Add("structured") } + return @($values) +} + +function Get-OutputCapabilities([object]$model) { + $values = [System.Collections.Generic.List[string]]::new() + foreach ($value in @($model.modalities.output)) { + if ($value -eq "text" -and -not $values.Contains($value)) { + $values.Add($value) + } + } + if ($values.Count -eq 0) { $values.Add("text") } + if ($model.structured_output) { $values.Add("structured") } + $values.Add("tools") + if ($model.reasoning) { $values.Add("reasoning") } + return @($values) +} + +function Get-Metadata([object]$model) { + $metadata = [ordered]@{} + foreach ($pair in @( + @("description", $model.description), + @("family", $model.family), + @("knowledge", $model.knowledge), + @("releaseDate", $model.release_date), + @("lastUpdated", $model.last_updated) + )) { + if (-not [string]::IsNullOrWhiteSpace([string]$pair[1])) { $metadata[$pair[0]] = [string]$pair[1] } + } + if ($null -ne $model.open_weights) { $metadata["openWeights"] = ([bool]$model.open_weights).ToString().ToLowerInvariant() } + return $metadata +} + +$providers = [System.Collections.Generic.List[object]]::new() +foreach ($providerId in $providerIds) { + $property = $source.PSObject.Properties[$providerId] + if ($null -eq $property) { continue } + $provider = $property.Value + $providerEndpoint = Get-ProviderEndpoint $providerId $provider + $models = [System.Collections.Generic.List[object]]::new() + foreach ($modelProperty in @($provider.models.PSObject.Properties | Sort-Object Name)) { + $model = $modelProperty.Value + if (-not $model.tool_call -or $model.status -eq "deprecated") { continue } + if (-not (@($model.modalities.input) -contains "text") -or + -not (@($model.modalities.output) -contains "text")) { continue } + if ($providerId -eq "openai" -and + $modelProperty.Name.Contains("realtime", [StringComparison]::OrdinalIgnoreCase)) { continue } + if ($providerId -eq "google-vertex" -and + (-not $modelProperty.Name.StartsWith("gemini-", [StringComparison]::Ordinal) -or + $modelProperty.Name -eq "gemini-3.1-flash-lite-preview")) { continue } + if ($providerId -eq "minimax" -or $providerId -eq "minimax-cn") { + if ($modelProperty.Name -notin @("MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3")) { continue } + } + if ($providerId -eq "xai" -and $modelProperty.Name -in @( + "grok-3", "grok-3-fast", "grok-4.20-0309-non-reasoning", + "grok-4.20-0309-reasoning", "grok-code-fast-1")) { continue } + if ($providerId -eq "nvidia" -and $modelProperty.Name.ToLowerInvariant() -in @( + "abacusai/dracarys-llama-3.1-70b-instruct", "bytedance/seed-oss-36b-instruct", + "deepseek-ai/deepseek-v4-flash", "deepseek-ai/deepseek-v4-pro", "google/gemma-2-2b-it", + "google/gemma-3n-e2b-it", "google/gemma-3n-e4b-it", "google/gemma-4-31b-it", + "meta/llama-3.2-1b-instruct", "meta/llama-4-maverick-17b-128e-instruct", + "microsoft/phi-4-mini-instruct", "minimaxai/minimax-m2.7", "mistralai/mistral-nemotron", + "nvidia/nemotron-mini-4b-instruct", "qwen/qwen3-next-80b-a3b-instruct", + "qwen/qwen3.5-397b-a17b", "sarvamai/sarvam-m", "upstage/solar-10.7b-instruct")) { continue } + $apiId = Get-ApiId $providerId $provider $modelProperty.Name + $modelEndpoint = Get-ModelEndpoint $providerId $apiId $providerEndpoint + if ([string]::IsNullOrWhiteSpace($modelEndpoint)) { $modelEndpoint = $null } + $contextWindow = [int64]($model.limit.context ?? 0) + $maximumOutput = [int64]($model.limit.output ?? 0) + if ($contextWindow -gt [int]::MaxValue -or $maximumOutput -gt [int]::MaxValue) { continue } + if ($contextWindow -gt 0 -and $maximumOutput -ge $contextWindow) { + $maximumOutput = [Math]::Max(0, $contextWindow - 1) + } + + $cost = [ordered]@{ + input = [decimal]($model.cost.input ?? 0) + output = [decimal]($model.cost.output ?? 0) + cacheRead = [decimal]($model.cost.cache_read ?? 0) + cacheWrite = [decimal]($model.cost.cache_write ?? 0) + } + $costTiers = [System.Collections.Generic.List[object]]::new() + $seenTierThresholds = [System.Collections.Generic.HashSet[long]]::new() + foreach ($tier in @($model.cost.tiers)) { + if ($null -eq $tier -or $tier.tier.type -ne "context" -or $null -eq $tier.tier.size) { continue } + $threshold = [long]$tier.tier.size + if ($threshold -le 0 -or -not $seenTierThresholds.Add($threshold)) { continue } + $costTiers.Add([ordered]@{ + above = $threshold + input = [decimal]($tier.input ?? 0) + output = [decimal]($tier.output ?? 0) + cacheRead = [decimal]($tier.cache_read ?? 0) + cacheWrite = [decimal]($tier.cache_write ?? 0) + }) + } + if ($costTiers.Count -gt 0) { $cost["tiers"] = @($costTiers) } + $reasoning = Get-ReasoningProfile $providerId $apiId $modelProperty.Name $model + $compatibility = Get-ModelCompatibility $providerId $apiId $modelEndpoint $modelProperty.Name $model + + $models.Add([ordered]@{ + id = [string]$model.id + name = [string]$model.name + api = $apiId + baseUrl = $modelEndpoint + contextWindow = [int]$contextWindow + maximumOutput = [int]$maximumOutput + input = @(Get-InputCapabilities $model) + output = @(Get-OutputCapabilities $model) + reasoning = @($reasoning.Levels) + reasoningValues = $reasoning.Values + cost = $cost + metadata = Get-Metadata $model + headers = Get-ModelHeaders $providerId + compatibility = $compatibility + }) + } + + if ($models.Count -eq 0) { continue } + $providerMetadata = [ordered]@{} + if ($provider.doc) { $providerMetadata["documentation"] = [string]$provider.doc } + if ($provider.env) { $providerMetadata["environmentVariables"] = (@($provider.env) -join ",") } + $providers.Add([ordered]@{ + id = $providerId + name = [string]$provider.name + endpoint = $providerEndpoint + metadata = $providerMetadata + models = @($models) + }) +} + +$payload = [ordered]@{ + version = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + providers = @($providers) +} + +$directory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Path $directory -Force | Out-Null +$payload | ConvertTo-Json -Depth 16 -Compress | Set-Content -Path $OutputPath -Encoding utf8NoBOM +$modelCount = ($providers | ForEach-Object { $_.models.Count } | Measure-Object -Sum).Sum +Write-Host "Generated $modelCount models across $($providers.Count) providers at $OutputPath"