## Summary
Adds GitCode Release mirroring for AIRI release assets.
- Adds a reusable `.github/scripts/publish-gitcode-release.sh` helper
that creates or reuses the matching GitCode release, compares GitHub and
GitCode asset names, downloads only missing `AIRI-*` and `latest-*.yml`
assets, uploads them through GitCode's upload URL API, and verifies the
final asset list.
- Hooks the mirror step into desktop, Android, and iOS release workflows
so assets produced by separate release jobs can converge on the same
GitCode release.
- Keeps the mirror idempotent so repeated or concurrent release
workflows skip already mirrored files.
## Why
GitHub Releases are slow for many mainland China users. GitCode provides
a domestic fallback release page, but manually copying installers after
every release is error-prone and tedious.
## Validation
- `bash -n .github/scripts/publish-gitcode-release.sh`
- YAML parsing for:
- `.github/workflows/release-tamagotchi.yml`
- `.github/workflows/release-pocket-android.yml`
- `.github/workflows/release-pocket-ios.yml`
- `git diff --check -- .github/scripts/publish-gitcode-release.sh
.github/workflows/release-tamagotchi.yml
.github/workflows/release-pocket-android.yml
.github/workflows/release-pocket-ios.yml`
- Local idempotency check against existing `v0.10.1` GitHub/GitCode
releases:
- GitCode release already exists
- all mirrored assets already present
- final asset verification passed
## Setup Required
The target repository needs these GitHub Secrets:
- `GITCODE_TOKEN`
- `GITCODE_OWNER`
- `GITCODE_REPO`
For the current GitCode project, `GITCODE_OWNER` should remain `MoeruAI`
because GitCode's API and release download paths currently resolve that
namespace even though the UI displays `moeru-ai`.
## Summary
- change the sponsors workflow to create or update a generated-assets PR
instead of pushing directly to `main`
- publish generated SponsorKit assets to
`automation/update-sponsors-svg`
- use the existing `PAT_SLASH_COMMAND_DISPATCH` token when available so
the generated PR can trigger downstream checks, with `github.token` as a
fallback
## Why
The sponsors generation step now succeeds, but direct pushes to `main`
are rejected by repository rules because required checks are expected. A
PR-based update path keeps generated assets behind the normal branch
protection flow.
## Verification
- `ruby -e 'require "yaml";
YAML.load_file(".github/workflows/sponsors-svg.yml"); puts "yaml ok"'`
- `ruby -e 'require "yaml";
wf=YAML.load_file(".github/workflows/sponsors-svg.yml");
run=wf.fetch("jobs").fetch("generate").fetch("steps").find { |s|
s["name"] == "Create or update sponsors PR" }.fetch("run");
IO.popen(["bash", "-n"], "w") { |io| io.write(run) }; abort "bash -n
failed" unless $?.success?; puts "bash syntax ok"'`
- `git diff --check`
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec moeru-lint .github/workflows/sponsors-svg.yml` (workflow
file is ignored by repo lint config; command exited 0 with ignored-file
warning)
## Summary
- Repair two stale Vitest lockfile references left after the current
main branch merge state.
- Restore `pnpm install --frozen-lockfile` so scheduled workflows can
reach their actual jobs.
## Testing
- `pnpm install --frozen-lockfile --ignore-scripts`
- `git diff --check`
## Context
The manually triggered `Update Sponsors SVG` workflow failed before
SponsorKit ran because `pnpm install --frozen-lockfile` could not find
the lockfile entry for a Vitest peer snapshot.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
- services/domain/llm-router and services/domain/user-deletion still had
colocated *.test.ts; moved them into the local tests/ subdir so every
service module follows the same layout as billing/, admin/*/, etc.
- routes/oidc/ wasn't an independent route group — it's wholly consumed
by routes/auth/index.ts (electron-callback relay + OIDC bearer token
helper). Moved to routes/auth/oidc/ to reflect that ownership.
- Fixed the relative imports in src/libs/tests/{auth,env,request-auth,
ws-auth}.test.ts. They were moved to libs/tests/ in the previous
commit without updating the './<source>' specifiers; typecheck only
caught it once the rewriter ran across the tree.
pnpm typecheck passes; eslint matches the main baseline.
Why
- Code, routes, service, worker, tests, and ai-context references for the
legacy flux_grant_batch flow were removed in the previous commit. The
schema file and the corresponding production tables were intentionally
left for a separate DDL-only PR (this one) so the destructive change is
easy to time and roll back.
What
- Delete src/schemas/flux-grant-batch.ts.
- Drop the re-export line in src/schemas/index.ts.
- drizzle-kit generate produced drizzle/0011_open_unus.sql:
DROP TABLE flux_grant_batch CASCADE;
DROP TABLE flux_grant_batch_recipient CASCADE;
CASCADE removes the 6 associated indexes in one shot.
- docs/ai-context/architecture-overview.md updated: the dead-code
reminder now points at the migration and explains the rollback story.
Also rolls in a pre-existing local move that was sitting uncommitted:
src/libs/{auth,env,request-auth,ws-auth}.test.ts → src/libs/tests/...
(aligning with the libs/tests/eventa-hono-adapter.test.ts placement that
was already on HEAD).
Deployment
- pnpm typecheck: passes.
- DO NOT run pnpm db:push on prod from this branch automatically. The
drop is intentionally a separate operator action that requires picking
a deploy window where no instance is still on an older image that
could try to read flux_grant_batch. Until 0011 is applied to prod the
table sits as an orphaned shell — safe to leave indefinitely.
Why
- src/services/ was an unordered mix of single-file services and module
directories with no shared classification axis, plus several long-dead
admin batch helpers that survived the move to the simpler synchronous
admin-flux-grants flow.
What
- services/ now has two top-level layers:
domain/ — DB state + business rules (billing, characters, chats,
flux, flux-transaction, llm-router, providers, request-log,
stripe, user-deletion, admin/{flux-grants,router-config})
adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
(flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.
Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
external consumers are kept.
Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
drizzle-kit generate diff stays empty. Removing them is a separate PR
that owns the drop-table migration for flux_grant_batch /
flux_grant_batch_recipient.
Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
(all are pre-existing node/prefer-global/buffer in envelope-crypto and
scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
run are the known pushSchema-per-worker infra cost, not a regression.
The two seed scripts are fully superseded by the new admin endpoint
`POST /api/admin/config/router` — same encryption, same configKV
writes, same `configkv:invalidate` publish, plus auth + audit + body
limits. Keeping both code paths created a drift risk on the AAD label
and the merge semantics.
Doc + test fallout:
- `e2e-llm-router.ts` now points readers to the admin endpoint for
the prerequisite seed step.
- `docs/ai-context/verifications/llm-router.md` and
`streaming-tts.md` get curl-based seed instructions; the 2026-05-15
llm-router evidence stays intact with a note that the script it
used has since been removed.
- The U9 follow-up entry in `llm-router.md` flips from "not shipped"
to "partially shipped" — ETag + HMAC publish are still deferred,
so the `config_write` / `config_invalid_hmac` Grafana panels stay
parked.
- Self-edit on the admin route + `app.ts` docstrings to drop the
earlier "scripts stay as break-glass" wording.
Replaces routine use of `scripts/seed-router-config.ts` and
`scripts/seed-streaming-tts.ts` with `POST /api/admin/config/router`.
Operators can now patch one provider at a time without shelling into
the Railway runner; the seed scripts stay as break-glass tools for
cold-boot and disaster recovery.
The endpoint accepts a discriminated-union slice list (openrouter /
azure / dashscope-cosyvoice / streaming-tts), envelope-encrypts
plaintext keys in-process (never echoed back), and supports
merge/reset modes plus dryRun. Writes go through the existing
configKV + Redis `configkv:invalidate` channel so multi-instance
deployments pick up changes within the pub/sub propagation window.
Guarded by the existing `authGuard + adminGuard` pair
(`ADMIN_EMAILS` allowlist + verified email).
Why:
- Add a real bidirectional streaming TTS path: raw LLM tokens are
forwarded to the upstream model (Volcengine v3 via the unspeech ws
bridge) without client-side segmentation, so the model owns sentence
splitting and audio chunks play as they arrive.
- Move audio endpoints out of /api/v1/openai/. `/audio/voices`,
`/audio/models`, `/audio/voices/streaming` are not real OpenAI public
APIs, and the streaming TTS surface has nothing to do with OpenAI —
keeping them under /openai/ mislabelled the contract.
- Introduce `capabilities.speech.transport` on ProviderDefinition so
future streaming providers (ElevenLabs / Cartesia / OpenAI Realtime)
opt in without touching Stage.vue or the session factory.
- Unify Stage.vue's TTS path through a single StageTtsSession so the
chat-orchestrator hooks no longer branch on provider id.
What:
- apps/server: new ws proxy /api/v1/audio/speech/ws bridges client ↔
unspeech with auth, pre-flight flux check, billing from upstream
session.finished.usage, OTel spans.
- apps/server: audio routes moved from /api/v1/openai/audio/* to
/api/v1/audio/* (hard cutover; 404 sentinel tests added).
- apps/server: new /api/v1/audio/voices/streaming proxy reads voices
from unspeech /api/voices?provider=volcengine.
- apps/server: new STREAMING_TTS_UPSTREAM configKV entry +
scripts/seed-streaming-tts.ts.
- stage-ui: new libs/speech/streaming-pipeline.ts opens one ws per LLM
intent (appendText / finish / cancel + onSentence / onError / onDone).
- stage-ui: new libs/speech/tts-session.ts — StageTtsSession interface
with segmenter and streaming adapters; factory dispatches by
capabilities.speech.transport instead of hard-coded provider id.
- stage-ui: providerOfficialSpeechStreaming with capabilities.speech =
{ transport: 'bidirectional-ws' }; settings page with model/voice
picker + ws-based preview.
- stage-ui: Stage.vue chat hooks collapsed to a single currentSession;
hot-swap watcher cancels mid-session on provider/voice/model change;
unmount cancels and drains playback.
Tests:
- 9 streaming-pipeline tests (happy path / buffered / error / cancel /
truncation)
- 11 tts-session tests (factory branch coverage + adapter contracts)
- 4 audio-speech-ws route tests (forwarding / billing / pre-flight /
config-missing)
- 3 legacy-path 404 sentinels in v1 route tests
- Verification doc updated to reflect automated coverage.
The `/v1/openai/{chat,audio}` handlers used to be silent past
`hono/logger`'s `<-- POST` / `--> 502` lines — no userId, no model,
no token counts, no flux billed. Operators looking at a real gateway
incident had to cross-reference traces, request-log rows, and billing
ledger entries by timestamp alone. For a gateway whose value is
auditable per-request metering, that's not enough.
Hoists `requestId = nanoid()` to handler entry so the same
correlation id flows through:
- the inbound log line (model / stream / messageCount or inputChars
for TTS)
- the per-stream / non-streaming delivered log (status / durationMs /
promptTokens / completionTokens / fluxConsumed)
- the upstream-error degraded path (warn level)
- the partial-debit and debit-failure paths (already used requestId)
- `billingService.consumeFluxForLLM` / `ttsMeter.accumulate` for
DB-level idempotency (replaces the previous local nanoid() calls)
handleListVoices gets a `debug`-level line — the route is high-
frequency from UI voice pickers and we don't want it in the regular
audit feed, but it's useful when chasing voice-picker drift bugs.
No new schemas, no metric emission changes; this is purely logger
output. Pairs with the cause-propagation change so errors carry the
upstream snippet AND the request can be traced end-to-end by id.
When `mapUpstreamError` produced the final 502/503/504, it only carried
`{triedKeys, triedUpstreams, lastStatusCode}` in `details`. The upstream
response body was `.cancel()`'d on the wire and the network error
message vanished into the catch arm — operators staring at a 502 had no
way to tell "OpenRouter region-blocked us" from "key revoked" from
"DNS failed" without re-probing the upstream by hand.
Now each recorded failure carries the diagnostic snippet:
- chat upstreams read at most 256 bytes of the failed body via a
drain-aware reader before cancelling the rest (socket still returns
to the pool, no fallback-storm pool exhaustion).
- TTS upstreams reuse `errorMessageFrom(err)` — adapters already bake
the status + body snippet into `err.message`, so one field carries
both.
- network / timeout attempts record `errorMessageFromUnknown(err)` so
"attempt-timeout" vs "ECONNRESET" vs "DNS failed" stays
distinguishable.
The collected `UpstreamAttempt[]` is attached to `ApiError.cause`
rather than `details`. SEC-5 (no upstream content in client-facing
response body) still holds — only the server-side logger + OTel pick
the cause up. `app.onError` now logs `{details, cause}` together so a
single log line tells the operator both the contract-level summary
and the actual upstream payload.
Adds a router.test.ts regression covering both shapes (HTTP 401 body
snippet + network ECONNRESET errorMessage), with an explicit assertion
that `details` does NOT contain the body text so SEC-5 doesn't drift.
DashScope dropped cosyvoice-v1 from its REST-supported model list. v2
(and v3+) speak a different shape: voice / format / sample_rate live
under `input`, not `parameters`; non-streaming responses return
`output.audio.url` (signed OSS URL) instead of inline `output.audio.data`
base64. The previous adapter sent v1-shaped bodies to a bare
`https://dashscope-intl.aliyuncs.com/api/v1` baseURL and parsed
`audio.data`, which 404'd before the migration and would 200-with-no-
audio after — both invisible regressions for the gateway.
Adapter changes:
- Rewrite request body to v2 schema (voice/format under input).
- Add follow-up GET against `output.audio.url`; stream into ArrayBuffer
with a 25 MB hard cap and explicit drain-tracking finally, so a
misbehaving URL cannot exhaust memory and a half-read body cannot
hang a connection.
- Re-document baseURL contract: adapters do NOT append path; ops must
configure the FULL endpoint URL (root cause of the original 404
storm). DEFAULT_COSYVOICE_MODEL bumped to `cosyvoice-v2`, default
voice to `longxiaochun_v2`.
Voice catalog: regenerated with 19 representative cosyvoice-v2 voices
(assistant / customer-service / child / en-US / en-GB / ja-JP / ko-KR)
so the frontend voice picker is no longer a 2-entry stub. Full catalog
(100+) remains on the Alibaba docs page — we'll sync on demand rather
than scrape.
Seed script: `--dashscope-region intl|cn` (default `intl`),
`--dashscope-upstream-model cosyvoice-v2`, baseURL now resolves to
`https://<host>/api/v1/services/audio/tts/SpeechSynthesizer` so a
mis-typed region or path cannot reintroduce the 404.
Tests: new dashscope-cosyvoice.test.ts covers v2 body shape (asserts
`parameters` absent — regression), audio.url follow-up fetch, 401
propagation with `.status`, empty-envelope falling back into the
router's recoverable-error path, and catalog freshness (no leftover v1
ids). Verified locally against the staging DashScope key: 200 +
playable mp3 end to end.
End-state of the multi-step KTD-5 / KTD-6 / U8 work. The knoway sidecar
is no longer reachable from server code; the router is required at boot
and now owns chat completions, TTS synthesis, and voice catalog listing.
Highlights:
- LLM_ROUTER_MASTER_KEY becomes required; app.ts drops the graceful-
skip branch and the chat fallback fetch path is gone.
- /audio/speech and /audio/voices route through new routeTts /
listTtsVoices entries that reuse the chat key-rotator + per-attempt
timeout + abort propagation.
- DEFAULT_CHAT_MODEL / DEFAULT_TTS_MODEL move from env to configKV so
default-model swaps are hot-reloadable via Pub/Sub.
- GATEWAY_BASE_URL removed from env schema, .env, .env.local, smoke,
verification harness. Redis upstream-voices cache deleted — catalogs
come from in-process adapter JSON.
- routeTts splits adapter error contract by ApiError statusCode:
4xx propagates without fallback; 5xx folds into the network-failure
fallback path. handleTTS wraps billing + span attribute in try/finally
to plug a span leak when ttsMeter.accumulate() throws.
- seed-router-config.ts rewritten with --merge (default) / --reset /
--dry-run modes and env-var key handoff (OPENROUTER_KEY / AZURE_KEY /
DASHSCOPE_KEY) so prod seed flows never put plaintext on the CLI.
Adds DashScope CosyVoice seeding.
Docs (CLAUDE.md, architecture-overview.md, transport-and-routes.md)
reflect the new boundary. verifications/llm-router.md replaces the
overstated "U1-U9 shipped" line with an evidence-vs-pending table.
Tests: full 40-file / 343-case server suite green. New regressions pin
ApiError 4xx → no-fallback, ApiError 5xx → fallback, TTS billing
failure → span closed and error propagated.
- Added a new ObservableGauge for distinct active users to track real active user count, mitigating session row inflation issues.
- Updated the Grafana dashboard to reflect changes, including the removal of redundant WS Connections panel and the addition of new metrics for active sessions and distinct users.
- Improved documentation for verification automation processes, outlining a structured approach to automate verification steps and maintain evidence of tests.
- Added a new PostHog client for capturing server-side business events such as Stripe webhooks and subscription state changes.
- Implemented various tracking functions for pricing funnel steps, character creation, and chat session starts.
- Enhanced the flux meter tests to handle partial charges and report unbilled flux correctly.
- Updated the CharacterDialog and Flux settings pages to track user interactions with analytics events.
- Introduced a mechanism to identify users on PostHog based on authentication state to ensure accurate funnel tracking.
- Added necessary dependencies for PostHog integration in the project.
## Summary
Adds the experimental Godot stage sidecar path for `stage-tamagotchi`.
This PR wires the existing Tamagotchi model selection flow into an
external Godot runtime window. The renderer gates Godot scene input to
VRM models, Electron main materialises the selected model bytes to a
local file, and the Godot sidecar receives the native path over a local
WebSocket bridge before importing and displaying the avatar at runtime.
## What Changed
- Added a typed Godot scene input contract with `format: "vrm"`.
- Added renderer-side VRM-only gating before sending selected model data
to Electron main.
- Added Electron main sidecar management for:
- launching Godot
- starting the local WebSocket bridge
- materialising selected VRM bytes under app `userData`
- forwarding scene apply messages to Godot
- optional remote debugging support
- Added Godot runtime scripts for:
- sidecar startup and WebSocket orchestration
- message envelope parsing
- avatar import and atomic replacement
- runtime VRM import through Godot `GLTFDocument`
- Added engine-local docs for runtime import, live debugging, vendor
patches, and current VRM support boundaries.
- Removed temporary tests after using them to verify the glue behaviour
locally, to keep the review surface smaller.
## Vendor Code Note
A large part of this PR is vendored Godot add-on code, not AIRI business
logic.
The bulk of the added files under:
- `engines/stage-tamagotchi-godot/addons/vrm/**`
- `engines/stage-tamagotchi-godot/addons/Godot-MToon-Shader/**`
comes from V-Sekai Godot VRM / MToon add-ons. These files are required
because Godot plugins are project-local source/assets rather than
package-manager dependencies.
The intended review scope for vendor code is limited to:
- source baseline metadata
- license/plugin config
- Godot-generated metadata notes
- the documented local patch in `addons/vrm/vrm_extension.gd`
The application/runtime code to review is mainly under:
- `apps/stage-tamagotchi/src/shared/eventa/index.ts`
- `apps/stage-tamagotchi/src/renderer/pages/settings/models/`
- `apps/stage-tamagotchi/src/main/services/airi/godot-stage/`
- `engines/stage-tamagotchi-godot/scripts/`
## Current Boundary
This is still an experimental G1 Godot sidecar path.
The runtime scene input contract accepts `.vrm` files only. The current
Godot runtime importer covers the VRM 0.x path used by the local fixture
through AIRI’s runtime bridge over the vendored VRM extension. VRM 1.0
editor import support exists in the vendored add-on, but the sidecar
runtime importer does not yet register the full `VRMC_*` extension set,
so this PR does not claim full VRM 1.0 runtime support.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>