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