Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/monitor-model-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Monitor Model Publication

on:
schedule:
- cron: '37 * * * *'
workflow_dispatch:

permissions:
contents: read
actions: read

concurrency:
group: monitor-model-catalog
cancel-in-progress: false

jobs:
monitor:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Detect missing validation and publication runs
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/monitor-model-publication.mjs
61 changes: 28 additions & 33 deletions .github/workflows/publish-model-catalog.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Publish Model Catalog
run-name: Model catalog (${{ inputs.environment == 'staging' && 'staging' || 'production' }})

on:
workflow_run:
Expand All @@ -12,7 +13,7 @@ on:
- '.gitignore'
- 'package.json'
- 'packages/ai/**'
- 'scripts/publish-model-catalog.mjs'
- 'scripts/*model*.mjs'
# GitHub schedules use UTC. Run hourly candidates across the CET/CEST
# boundaries; the publish job only uploads at 10:17, 12:17, and 14:17
# Europe/Vienna time.
Expand All @@ -26,18 +27,26 @@ on:
default: 'main'
type: string
publish:
description: 'Upload the generated catalog to production R2'
description: 'Upload the generated catalog to the selected R2 environment'
required: true
default: false
type: boolean

environment:
description: 'Catalog upload environment'
type: choice
options: [staging, production]
default: staging

permissions:
contents: read

jobs:
generate:
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }}
runs-on: ubuntu-latest
outputs:
source_sha: ${{ steps.source.outputs.sha }}
env:
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
steps:
Expand All @@ -47,6 +56,10 @@ jobs:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false

- name: Resolve source SHA
id: source
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -57,6 +70,8 @@ jobs:
run: npm ci --ignore-scripts

- name: Generate model catalog JSON
env:
MODEL_CATALOG_SOURCE_SHA: ${{ steps.source.outputs.sha }}
run: npm run generate:model-catalog

- name: Validate model catalog JSON
Expand All @@ -74,15 +89,16 @@ jobs:
if: ${{ github.ref == 'refs/heads/main' && ((github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.source_ref == 'main')) }}
needs: generate
runs-on: ubuntu-latest
environment: diffex-model-upload
environment: ${{ inputs.environment == 'staging' && 'diffex-model-upload-staging' || 'diffex-model-upload-production' }}
concurrency:
group: publish-model-catalog-r2
cancel-in-progress: true
group: publish-model-catalog-${{ inputs.environment == 'staging' && 'staging' || 'production' }}
cancel-in-progress: false
env:
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
SOURCE_REF: ${{ needs.generate.outputs.source_sha }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: 'true'
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
CATALOG_BUCKET: ${{ inputs.environment == 'staging' && 'diffex-artifacts-staging' || 'diffex-artifacts' }}
R2_ENDPOINT: https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -109,39 +125,18 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
MANUAL_PUBLISH: ${{ inputs.publish || false }}
run: |
set -euo pipefail

local_day="$(TZ=Europe/Vienna date +%u)"
local_hour_text="$(TZ=Europe/Vienna date +%H)"
local_hour="$((10#$local_hour_text))"
local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')"
allowed=false
reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window"

if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then
allowed=true
reason="manual publication"
elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then
if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then
allowed=true
reason="business-hours publication"
else
reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication"
fi
fi

echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
echo "R2 publication allowed: $allowed ($reason; local time: $local_time)"
run: node scripts/model-publication-window.mjs >> "$GITHUB_OUTPUT"

- name: Publish model catalog to R2
if: steps.publication-window.outputs.allowed == 'true'
env:
GH_TOKEN: ${{ github.token }}
AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }}
run: |
node scripts/publish-model-catalog.mjs \
--input .artifacts/model-catalog \
--bucket diffex-artifacts \
--bucket "$CATALOG_BUCKET" \
--endpoint "$R2_ENDPOINT" \
--source-commit "$(git rev-parse HEAD)"
--source-commit "$SOURCE_REF" \
--require-main
6 changes: 4 additions & 2 deletions docs/identity-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ No package, image, release asset, or source archive is published by this change.
| Product author and website | DiffexAI, `https://diffex.ai/` |
| Legal copyright owner | Luntraa, Inc. |
| Release API | `https://api.diffex.ai/api/latest-version`, owned by `diffex-cloud` |
| Radius and catalog services | Approved external integrations recorded in the candidate handoff |
| Model catalog API | `https://api.diffex.ai/api/models/providers/{providerId}`, owned by `diffex-cloud` |
| Radius services | Approved external integrations recorded in the candidate handoff |
| Security contact | Existing private access to a Diffex repository owner; no dedicated mailbox or public reporting channel claimed |
| Issue authorization | Write/admin permission on the current Diffex repository |

Diffex and Luntraa, Inc. are unrelated to Earendil.
The former staff-team authorization, security mailbox reference, announcement, and product affiliation wording have been removed.
The owner explicitly retained OpenAI Codex and xAI OAuth in addition to the release audit's Radius, catalog, and provider-attribution integrations.
The owner explicitly retained OpenAI Codex and xAI OAuth in addition to the release audit's Radius and provider-attribution integrations.
The catalog authority is now Diffex; see [the catalog contract](model-catalog.md).
The exact residual inventory is [identity-residual-allowlist.md](../identity-residual-allowlist.md).

| Workspace | Final package | Publication |
Expand Down
133 changes: 133 additions & 0 deletions docs/model-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Diffex model catalog contract

The CLI repository owns generation, validation, publication, and persisted client overlays.
`diffex-cloud/services/public-api` owns anonymous serving through `https://api.diffex.ai/api/models/providers/{providerId}`.
No database is involved.
Upstream metadata sources and generator overrides remain unchanged.

## Storage and publication

Account `6b24afa849f8254ab5e6e2d1956239cc` hosts private buckets `diffex-artifacts-staging` and `diffex-artifacts`.
The Worker binds the matching bucket as `MODEL_CATALOGS`.
GitHub environments `diffex-model-upload-staging` and `diffex-model-upload-production` each contain bucket-scoped `DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID` and `DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY` secrets and permit only `main`.
Neither bucket needs public access.

The strict JSON export includes `models.json`, sorted `providers.json`, `providers/{providerId}.json`, and `generation.json` with `generatedAt` and the workflow's resolved `sourceCommit`.
All model fields pass the bundled-model validator before export.
Publication additionally requires matching provider shards, `anthropic`, `openai`, `openrouter`, and at least 500 models.

The revision is `sha256-` followed by the SHA-256 of the exact `models.json` bytes.
Immutable objects live under `models/v1/revisions/{revision}/`.
That directory contains the models, provider list, shards, and an immutable `publication.json`:

```json
{
"schemaVersion": 1,
"minimumDiffexVersion": "0.3.3",
"revision": "sha256-<64 lowercase hexadecimal characters>",
"sourceCommit": "<40 lowercase hexadecimal characters>",
"generatedAt": "2026-10-26T10:00:00.000Z",
"providerIds": ["anthropic", "openai", "openrouter"],
"providerCount": 3,
"modelCount": 510,
"providers": {
"openai": {
"key": "models/v1/revisions/sha256-<digest>/providers/openai.json",
"sha256": "<SHA-256 of the exact provider shard bytes>",
"modelCount": 170
}
}
}
```

The example abbreviates the provider map; every listed provider must have an entry, and counts must sum exactly.
Identical-content retries preserve the first manifest's source and timestamp.
The initial compatibility floor is `0.3.3`; raise it only with metadata requiring newer client behavior.
Raising a floor requires a new content revision, since a published manifest cannot be edited.

The mutable `models/v1/index.json` contains `schemaVersion: 1`, `defaultRevision`, `catalogs`, `validatedAt`, and `validationSourceCommit`.
Each tier has `minimumDiffexVersion`, `revision`, `sourceCommit`, `generatedAt`, `providerCount`, and `modelCount`.
Publication replaces only the matching compatibility floor and retains other tiers.
Every successful publication, including unchanged content, advances validation evidence independently of generation age.

All revision objects use S3 `PutObject` with `If-None-Match: *` and are downloaded and verified before index advancement.
The publisher verifies an existing immutable manifest instead of replacing it.
The index is updated with `If-Match` against the ETag read before uploads, or `If-None-Match: *` for the initial write.
Conflicts fail the run; regenerate from current main and retry.
Interrupted uploads leave the previously selected revision intact.
R2's [consistency guarantees](https://developers.cloudflare.com/r2/reference/consistency/) allow verification directly through private storage without public caches.

## Serving and cache provenance

The only new production route is `https://api.diffex.ai/api/models/providers/*`.
Release discovery stays on its existing Worker route; auth, webhooks, `/v1/*`, and production health routes remain on Render.
Provider IDs must match `[a-z0-9][a-z0-9-]{0,99}` with no extra path segments or encoded separators.
Storage keys come exclusively from the selected, validated publication manifest.

Anonymous `GET` and `HEAD` select the highest compatibility floor supported by the `diffex/<version>` user-agent token.
A prerelease does not satisfy the same stable-version floor.
Unrecognized or absent Diffex versions select `defaultRevision`.
No compatible tier returns `501`; an unknown provider returns `404`; unsupported methods return `405` with `Allow: GET, HEAD`.
Missing, malformed, oversized, corrupt, or inaccessible storage returns `503` and never exposes internal storage errors.

Successful responses retain the provider's model-ID-keyed JSON object and return `Content-Type: application/json; charset=utf-8`, `Cache-Control: public, no-cache`, and `Vary: User-Agent`.
The quoted ETag is `"{revision}:{providerId}"`.
Cloudflare may add the weak `W/` prefix when compressing a response; clients retain the received validator and probes compare its opaque value.
`Last-Modified` is the immutable generation timestamp rounded to HTTP-date precision.
`If-None-Match` takes precedence over `If-Modified-Since`, supports weak tags and lists for GET/HEAD revalidation, and returns bodyless `304` on a match.
Every HEAD response is bodyless.
The mutable index is read directly from R2 on each request.

Client cache entries record the exact provider URL as `catalogUrl` and the selecting CLI version as `catalogClientVersion`.
Only entries matching both values may restore models, suppress refreshes for four hours, or send a cached ETag.
Legacy or mismatched entries fall back to bundled models and refresh unconditionally when network access is allowed.
Newer bundled metadata retains precedence.
Successful bodies are fully validated before persistence; failures and cancellation preserve the last valid matching cache.
`diffex update --models` forces refresh, including rollback discovery.

## Validation and rollout

Run focused publisher and client tests and `npm run check` in the CLI repository.
Run `npm run check` in `diffex-cloud/services/public-api`.
The opt-in local integration requires the companion checkout, AWS CLI with conditional-put support, and tmux:

```sh
DIFFEX_CLOUD_DIR=/path/to/diffex-cloud node scripts/catalog-local-e2e.mjs
```

It exercises the actual publisher through local R2's S3 API, the Worker runtime, CLI update and persisted cache, the interactive selector, and offline startup using an isolated agent directory.
It blocks unrelated outbound requests and performs no model inference.

Coordinate repository changes before enabling scheduled publication.
Rehearse against staging with staging-only credentials and verify fixture cleanup.
From the CLI repository, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION=auto` in an approved environment, then run:

```sh
node scripts/catalog-staging-rehearsal.mjs --staging
```

The rehearsal requires the catalog Worker to be deployed to staging first.
It creates an isolated synthetic revision, checks interrupted uploads and conditional conflicts, exercises the deployed endpoint and isolated CLI, restores the prior index conditionally, and deletes and verifies only its fixture objects.
It refuses to overwrite a concurrent index change and retains recovery evidence if cleanup fails.
Seed production with a validated real catalog, deploy the narrow Worker route, and verify real `200`/`304` responses and unchanged release/Render routes before shipping the client default.
Do not publish synthetic fixture models to production.

## Monitoring and rollback

Weekday publication candidates preserve 10:17, 12:17, and 14:17 Europe/Vienna and the existing successful-main-CI window of 10:00 through 14:59.
Manual publication bypasses the window; PRs only validate.
Generation and publishing code use one resolved SHA; the publisher checks current main before upload and immediately before index advancement.
Uploads serialize per environment without cancelling active uploads.

`monitor-model-catalog.yml` checks successful generation and actual production upload steps separately, with one hour of scheduling grace and weekend/DST handling.
`diffex-cloud`'s existing monitor probes availability, HEAD, ETag/date validators, release discovery, and Render routing.
Old generation timestamps are healthy when content is unchanged.
Both [GitHub scheduled workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) can be delayed or dropped, so configure an independent external heartbeat for the monitor and confirm failure notifications after merge.
Repository changes alone do not prove scheduled execution or notification delivery.

Retain immutable revisions for rollback.
Download and preserve the current index bytes and ETag, verify every object referenced by the desired historical index, then replace the index with `If-Match` against the captured current ETag.
Do not force an index update after a conditional conflict.
Alternatively, roll back to a verified Worker deployment while preserving catalog bindings and narrow routes.
Worker rollback does not roll back R2 objects.
Clients discover a catalog rollback on their next refresh or through `diffex update --models`.
5 changes: 2 additions & 3 deletions identity-residual-allowlist.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ Paths below are descriptive coordinates; the full source context is authoritativ
| `packages/coding-agent/src/core/provider-attribution.ts:58` | `Pi` | approved integration | Provider attribution retained by the release audit; optional headers default off. |
| `packages/coding-agent/src/core/provider-attribution.ts:64` | `pi-coding-agent` | approved integration | Provider attribution retained by the release audit; optional headers default off. |
| `packages/coding-agent/src/core/provider-attribution.ts:80` | `pi` | approved integration | OpenCode session/client attribution retained independently of the optional attribution toggle. |
| `packages/coding-agent/src/core/remote-catalog-provider.ts:6` | `pi.dev` | approved integration | Pi-supported model catalogs retained by the release audit. |
| `packages/coding-agent/src/core/remote-catalog-provider.ts:44` | `pi.dev` | approved integration | Pi-supported model catalogs retained by the release audit. |

The owner explicitly approved retention of OpenAI Codex and xAI OAuth during this implementation.
The release audit approves Radius gateway/OAuth, Pi-supported model catalogs, and provider attribution.
The release audit approves Radius gateway/OAuth and provider attribution.
Model catalogs use the Diffex-owned API described in [the catalog contract](docs/model-catalog.md); the former catalog exceptions are removed.
These entries permit those integrations only; they do not authorize install reporting, update authority, arbitrary traffic to the same host, or new dependencies.
The source check covers tracked and unignored package source, examples, configuration, workflow files, operational extensions, dependency metadata, and root identity documents.
Tests and their fixtures describe rejected historical inputs and are not shipped runtime configuration.
Expand Down
Loading
Loading