diff --git a/.gitea/workflows/concurrency.yaml b/.gitea/workflows/concurrency.yaml new file mode 100644 index 0000000..d40d22b --- /dev/null +++ b/.gitea/workflows/concurrency.yaml @@ -0,0 +1,32 @@ +name: codex-auth-concurrency + +# Heavy: spawns multiple real processes that contend on one credential file. +# Runs only when a pushed commit's message contains "[lock]", or on manual +# dispatch — not on every push. +on: + push: + workflow_dispatch: + +jobs: + concurrency: + if: ${{ contains(github.event.head_commit.message, '[lock]') || github.event_name == 'workflow_dispatch' }} + # Adjust to a label your Gitea Act runner advertises if it isn't ubuntu-latest. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Install llm-client dependencies + working-directory: llm-client + run: bun install --frozen-lockfile + + - name: Cross-process codex refresh concurrency test + env: + CONC_PROCESSES: "8" + CONC_RUNS: "3" + run: bun run test-scripts/concurrency/run.ts diff --git a/.github/workflows/client-build.yml b/.github/workflows/client-build.yml new file mode 100644 index 0000000..4d08536 --- /dev/null +++ b/.github/workflows/client-build.yml @@ -0,0 +1,54 @@ +name: client-build + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: macos-arm64 + script: build:macos-arm64 + artifact: aimdware-router-macos-arm64 + - os: macos-15 + target: macos-x64 + script: build:macos-x64 + artifact: aimdware-router-macos-x64 + - os: ubuntu-latest + target: linux-arm64 + script: build:linux-arm64 + artifact: aimdware-router-linux-arm64 + - os: ubuntu-latest + target: linux-x64 + script: build:linux-x64 + artifact: aimdware-router-linux-x64 + + defaults: + run: + working-directory: llm-client + + steps: + - uses: actions/checkout@v7 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bun run typecheck + + - run: bun test + + - run: bun run ${{ matrix.script }} + + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: llm-client/dist/${{ matrix.artifact }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da97dc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +third_party/ +node_modules/ +dist/ +*.log +.DS_Store + +.*.bun-build +test-functional/runs/ +test-functional/opencode.json + +# Personal runbook — do not commit +AGENT.md +.claude/ +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..26030e6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +# Run with: uv run --project backend prek run --all-files +# Install : uv run --project backend prek install (one-time, hooks .git/hooks) +# +# Tooling lives where the language lives: +# - oxfmt + oxlint: llm-client/ (bun devDeps + .oxfmtrc.json + .oxlintrc.json) +# - ruff: backend/ (uv dev dep + [tool.ruff] in pyproject) +# - prek itself: backend/ (uv dev dep) +exclude: '^(third_party/|.*/dist/|.*/node_modules/|.*/\.venv/|backend/alembic/versions/)' + +repos: + # --- generic --- + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + # --- Python: backend/ --- + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + - id: ruff-check + types_or: [python, pyi] + args: ["--fix"] + - id: ruff-format + types_or: [python, pyi] + + # --- TypeScript: llm-client/ --- + - repo: https://github.com/oxc-project/mirrors-oxlint + rev: v1.41.0 + hooks: + - id: oxlint + types_or: [javascript, jsx, ts, tsx] + verbose: true + + - repo: local + hooks: + - id: oxfmt + name: oxfmt + # pre-commit runs from repo root; cd into the workspace where + # oxfmt is installed as a Bun devDep + .oxfmtrc.json lives. + entry: bash -c 'cd llm-client && bun run format' + language: system + files: '^llm-client/.*\.(ts|tsx|js|jsx)$' + pass_filenames: false diff --git a/README.md b/README.md index 890bee3..c225d1b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A teaching-team toolkit for monitoring student AI usage in coursework. Three components: - **Backend** (Python/FastAPI/Postgres) — stores per-record metadata + sha256 + jbox URI. Holds no prompt/response content. -- **LLM client** (Bun single binary) — runs on the student's machine. OpenAI-compatible local API; forwards to a student-configured upstream with the student's own LLM key; uploads the response JSON to the student's jbox via `rclone` over the local Tbox WebDAV endpoint. +- **LLM client** (Bun single binary) — runs on the student's machine. OpenAI-compatible local API; forwards to a student-configured upstream (the student's own LLM key, or a ChatGPT/Codex or GitHub Copilot subscription); uploads the response JSON to the student's jbox via a WebDAV PUT to the local Tbox endpoint. - **Admin script** (`aimdware-admin`, Python CLI) — TT-side tool. Manages users / courses / enrollments / tokens via direct Postgres; fetches blobs from jbox for inspection. ## Architecture @@ -15,7 +15,7 @@ Three components: │ Student's own laptop │ │ │ │ coding agent ──▶ aimdware router ──▶ OpenAI / DeepSeek / │ -│ (Cline / Aider / (course token + upstream LLM │ +│ (Cline / Aider / (student token + upstream LLM │ │ OpenCode...) student's LLM key) │ │ │ │ │ └──────────────────────────┼─────────────┼─────────────────────────┘ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cc2a094 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +.venv/ +.env +__pycache__/ +*.egg-info/ +.pytest_cache/ +*.pyc +.mypy_cache/ +.ruff_cache/ +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..191a3e5 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,55 @@ +"""Alembic env. Reads DB URL from $AIMDWARE_DATABASE_URL via our settings, +and targets SQLModel.metadata so `alembic revision --autogenerate` works.""" + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +# Importing the models registers every table with SQLModel.metadata. +from aimdware_backend import models # noqa: F401 +from aimdware_backend.settings import settings +from alembic import context + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject the runtime DB URL so the same env vars work for migrations and the app. +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # required for sqlite ALTER COLUMN ops + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..018cc10 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # sqlmodel.sql.sqltypes.AutoString shows up in autogenerated diffs +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/1cc659f78871_0002_unique_session_turn.py b/backend/alembic/versions/1cc659f78871_0002_unique_session_turn.py new file mode 100644 index 0000000..1823d06 --- /dev/null +++ b/backend/alembic/versions/1cc659f78871_0002_unique_session_turn.py @@ -0,0 +1,41 @@ +"""0002 unique session turn + +Adds UNIQUE(session_id, turn_count) on context_records so two routers +racing on the same session can never silently collide on a tiebreaker +when /admin/session//payload picks the "latest" turn. + +Revision ID: 1cc659f78871 +Revises: ad7b66d6bff9 +Create Date: 2026-05-13 08:07:21.015170 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 (kept for autogen-friendly imports) +import sqlmodel # noqa: F401 + + +revision: str = '1cc659f78871' +down_revision: Union[str, Sequence[str], None] = 'ad7b66d6bff9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.create_unique_constraint( + 'ux_context_records_session_turn', + ['session_id', 'turn_count'], + ) + # NOTE: autogen wants to drop the ux_student_tokens_active_per_user + # partial index here because SQLModel.metadata doesn't know about it + # (partial-where isn't expressible in the declarative model). The + # index is correct and needed for "one active token per user", so we + # intentionally do NOT drop it. + + +def downgrade() -> None: + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.drop_constraint( + 'ux_context_records_session_turn', type_='unique' + ) diff --git a/backend/alembic/versions/ad7b66d6bff9_0001_initial_schema.py b/backend/alembic/versions/ad7b66d6bff9_0001_initial_schema.py new file mode 100644 index 0000000..6800f59 --- /dev/null +++ b/backend/alembic/versions/ad7b66d6bff9_0001_initial_schema.py @@ -0,0 +1,142 @@ +"""0001 initial schema + +Revision ID: ad7b66d6bff9 +Revises: +Create Date: 2026-05-13 08:06:01.938535 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'ad7b66d6bff9' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('courses', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('code', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('semester', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('courses', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_courses_code'), ['code'], unique=True) + + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('jaccount', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) + batch_op.create_index(batch_op.f('ix_users_jaccount'), ['jaccount'], unique=True) + + op.create_table('context_records', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('course_id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Uuid(), nullable=False), + sa.Column('turn_count', sa.Integer(), nullable=False), + sa.Column('ts', sa.DateTime(), nullable=False), + sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('prompt_tokens', sa.Integer(), nullable=False), + sa.Column('completion_tokens', sa.Integer(), nullable=False), + sa.Column('router_version', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('client_meta', sa.JSON(), nullable=True), + sa.Column('blob_uri', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('blob_hash', sa.LargeBinary(), nullable=True), + sa.Column('blob_size', sa.BigInteger(), nullable=True), + sa.Column('blob_status', sa.Enum('pending', 'uploaded', 'verified', 'tampered', 'missing', name='blobstatus'), nullable=False), + sa.Column('blob_verified_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_context_records_blob_status'), ['blob_status'], unique=False) + batch_op.create_index(batch_op.f('ix_context_records_course_id'), ['course_id'], unique=False) + batch_op.create_index(batch_op.f('ix_context_records_session_id'), ['session_id'], unique=False) + batch_op.create_index(batch_op.f('ix_context_records_ts'), ['ts'], unique=False) + batch_op.create_index(batch_op.f('ix_context_records_user_id'), ['user_id'], unique=False) + + op.create_table('enrollments', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('course_id', sa.Uuid(), nullable=False), + sa.Column('role', sa.Enum('student', 'admin', name='role'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'course_id') + ) + op.create_table('student_tokens', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('token_hash', sa.LargeBinary(), nullable=True), + sa.Column('prefix', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('revoked_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('student_tokens', schema=None) as batch_op: + batch_op.create_index('ix_student_tokens_user', ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_student_tokens_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + # Partial unique index: at most one active (revoked_at IS NULL) token + # per user. SQLModel can't express partial uniqueness natively, so we + # add it explicitly here. Safe on both sqlite and postgres. + op.create_index( + 'ux_student_tokens_active_per_user', + 'student_tokens', + ['user_id'], + unique=True, + sqlite_where=sa.text('revoked_at IS NULL'), + postgresql_where=sa.text('revoked_at IS NULL'), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index('ux_student_tokens_active_per_user', table_name='student_tokens') + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('student_tokens', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_student_tokens_user_id')) + batch_op.drop_index('ix_student_tokens_user') + + op.drop_table('student_tokens') + op.drop_table('enrollments') + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_context_records_user_id')) + batch_op.drop_index(batch_op.f('ix_context_records_ts')) + batch_op.drop_index(batch_op.f('ix_context_records_session_id')) + batch_op.drop_index(batch_op.f('ix_context_records_course_id')) + batch_op.drop_index(batch_op.f('ix_context_records_blob_status')) + + op.drop_table('context_records') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_jaccount')) + batch_op.drop_index(batch_op.f('ix_users_email')) + + op.drop_table('users') + with op.batch_alter_table('courses', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_courses_code')) + + op.drop_table('courses') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b984da6ac5c5_0003_add_assignment_to_context_records.py b/backend/alembic/versions/b984da6ac5c5_0003_add_assignment_to_context_records.py new file mode 100644 index 0000000..a07b3cb --- /dev/null +++ b/backend/alembic/versions/b984da6ac5c5_0003_add_assignment_to_context_records.py @@ -0,0 +1,50 @@ +"""0003 add assignment to context_records + +Free-form course-scoped label (homework slug / lab number / exam name). +The router config from this point on requires `assignment` alongside +`course`, so every new ContextRecord carries one. + +For existing rows on an upgraded database: backfill to empty string so +the NOT NULL constraint holds. + +Revision ID: b984da6ac5c5 +Revises: 1cc659f78871 +Create Date: 2026-05-14 14:32:39.058999 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 (kept for autogen-friendly imports) + + +revision: str = 'b984da6ac5c5' +down_revision: Union[str, Sequence[str], None] = '1cc659f78871' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'assignment', + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + server_default='', + ) + ) + batch_op.create_index( + batch_op.f('ix_context_records_assignment'), + ['assignment'], + unique=False, + ) + # NOTE: autogen wants to drop ux_student_tokens_active_per_user + # here because SQLModel.metadata doesn't know about partial indexes. + # The index is correct and kept; do NOT drop it. + + +def downgrade() -> None: + with op.batch_alter_table('context_records', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_context_records_assignment')) + batch_op.drop_column('assignment') diff --git a/backend/alembic/versions/c0a1d2e3f4b5_0004_add_student_id.py b/backend/alembic/versions/c0a1d2e3f4b5_0004_add_student_id.py new file mode 100644 index 0000000..b3d4b9c --- /dev/null +++ b/backend/alembic/versions/c0a1d2e3f4b5_0004_add_student_id.py @@ -0,0 +1,33 @@ +"""0004 add student_id to users + +Roster 学号. Nullable and not unique — rosters may carry blanks and we don't +want a stray duplicate to fail a batch import. jaccount stays the identity. + +Revision ID: c0a1d2e3f4b5 +Revises: b984da6ac5c5 +Create Date: 2026-06-21 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 (kept for autogen-friendly imports) + + +revision: str = "c0a1d2e3f4b5" +down_revision: Union[str, Sequence[str], None] = "b984da6ac5c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("student_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("student_id") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..2e6f363 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "aimdware-backend" +version = "0.0.0" +description = "aimdware backend: ingest API + token validation" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "sqlmodel>=0.0.22", + "uvicorn[standard]>=0.32", + "alembic>=1.13", + "pydantic-settings>=2.5", + "httpx>=0.28.1", +] + +[project.scripts] +aimdware-admin = "aimdware_backend.admin_cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "httpx>=0.27", + "anyio>=4.6", + "ruff>=0.14.5", + "prek>=0.2", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["alembic/versions"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "W", # pycodestyle warnings + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify +] +ignore = ["E501"] # line-too-long: handled by formatter + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/aimdware_backend"] + +# Make this directory a workspace ROOT so it is not auto-included as a +# member of any parent workspace (e.g. $HOME/pyproject.toml). +[tool.uv.workspace] +members = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-q" +asyncio_mode = "auto" diff --git a/backend/scripts/seed_for_e2e.py b/backend/scripts/seed_for_e2e.py new file mode 100644 index 0000000..50a7d31 --- /dev/null +++ b/backend/scripts/seed_for_e2e.py @@ -0,0 +1,92 @@ +"""One-shot seeder for the e2e smoke run. + +Inserts a User + Course + Enrollment (role=student) + StudentToken, and +prints the plaintext token to stdout so the calling script can capture +and feed it to the router. + +The plaintext can be passed in via $E2E_PLAINTEXT for determinism; +otherwise a fresh one is generated. + +Usage: + AIMDWARE_DATABASE_URL=sqlite:///./aimdware.db \ + uv run python scripts/seed_for_e2e.py +""" + +from __future__ import annotations + +import hashlib +import os +import secrets +import sys +from uuid import uuid4 + +from sqlmodel import Session, SQLModel, select + +from aimdware_backend.db import get_engine +from aimdware_backend.models import ( + Course, + Enrollment, + Role, + StudentToken, + User, +) + + +def main() -> int: + engine = get_engine() + SQLModel.metadata.create_all(engine) + + plaintext = os.environ.get("E2E_PLAINTEXT") + if not plaintext: + plaintext = "st_" + secrets.token_urlsafe(32) + + jaccount = os.environ.get("E2E_JACCOUNT", "zhangsan") + course_code = os.environ.get("E2E_COURSE", "ECE4721J") + + with Session(engine) as s: + user = s.exec(select(User).where(User.jaccount == jaccount)).first() + if user is None: + user = User( + id=uuid4(), + display_name="E2E Student", + email=f"{jaccount}@sjtu.edu.cn", + jaccount=jaccount, + ) + s.add(user) + s.commit() + s.refresh(user) + + course = s.exec(select(Course).where(Course.code == course_code)).first() + if course is None: + course = Course( + id=uuid4(), + code=course_code, + title="Intro to Systems", + semester="2026-spring", + ) + s.add(course) + s.commit() + s.refresh(course) + + enrol = s.exec( + select(Enrollment).where( + Enrollment.user_id == user.id, + Enrollment.course_id == course.id, + ) + ).first() + if enrol is None: + s.add(Enrollment(user_id=user.id, course_id=course.id, role=Role.student)) + s.commit() + + digest = hashlib.sha256(plaintext.encode()).digest() + existing = s.exec(select(StudentToken).where(StudentToken.token_hash == digest)).first() + if existing is None: + s.add(StudentToken(user_id=user.id, token_hash=digest, prefix=plaintext[:8])) + s.commit() + + print(plaintext, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/src/aimdware_backend/__init__.py b/backend/src/aimdware_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/aimdware_backend/admin_auth.py b/backend/src/aimdware_backend/admin_auth.py new file mode 100644 index 0000000..132acbc --- /dev/null +++ b/backend/src/aimdware_backend/admin_auth.py @@ -0,0 +1,38 @@ +"""Shared-secret bearer auth for /admin/* endpoints.""" + +from __future__ import annotations + +import hmac +from typing import Annotated + +from fastapi import Header, HTTPException, status + +from aimdware_backend.settings import settings + + +def authenticate_admin( + authorization: Annotated[str | None, Header()] = None, +) -> None: + """Gate /admin/* on a constant-time comparison against AIMDWARE_ADMIN_SECRET. + + If the secret is unset, /admin/* is disabled (503) — refuse rather + than open up. + """ + if not settings.admin_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="admin endpoints disabled (AIMDWARE_ADMIN_SECRET unset)", + ) + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="missing admin bearer", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = authorization[len("bearer ") :].strip() + if not hmac.compare_digest(token, settings.admin_secret): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid admin secret", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py new file mode 100644 index 0000000..849dd2f --- /dev/null +++ b/backend/src/aimdware_backend/admin_cli.py @@ -0,0 +1,597 @@ +"""TT-side admin CLI for the aimdware backend. + +Operates directly on the configured database. Run as: + + AIMDWARE_DATABASE_URL=sqlite:///./aimdware.db \\ + uv run aimdware-admin token issue --user alice + +Subcommands: + user create / user list + course create / course list + enroll + token issue / token revoke / token list + record list / record payload +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import inspect +import json +import secrets +import sys +from typing import Any +from uuid import UUID + +from sqlalchemy import text +from sqlmodel import Session, select + +from aimdware_backend.db import get_engine +from aimdware_backend.models import ( + BlobStatus, + ContextRecord, + Course, + Enrollment, + Role, + StudentToken, + User, + utcnow, +) +from aimdware_backend.roster import RosterRow, read_roster + +# --- importable command functions (also covered by tests) --------------- + + +DEFAULT_EMAIL_DOMAIN = "sjtu.edu.cn" + + +def derive_email(jaccount: str, domain: str = DEFAULT_EMAIL_DOMAIN) -> str: + """SJTU jaccount is the email local-part, so derive it deterministically.""" + return f"{jaccount}@{domain}" + + +def user_create( + session: Session, + *, + jaccount: str, + email: str, + display_name: str, + student_id: str | None = None, +) -> User: + """Create a new User row and return it.""" + user = User( + jaccount=jaccount, + email=email, + display_name=display_name, + student_id=student_id, + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def user_get(session: Session, jaccount: str) -> User: + """Return the User with this jaccount, or raise LookupError.""" + user = session.exec(select(User).where(User.jaccount == jaccount)).first() + if user is None: + raise LookupError(f"no user with jaccount={jaccount!r}") + return user + + +def course_create(session: Session, *, code: str, title: str, semester: str) -> Course: + """Create a new Course row and return it.""" + course = Course(code=code, title=title, semester=semester) + session.add(course) + session.commit() + session.refresh(course) + return course + + +def course_get(session: Session, code: str) -> Course: + """Return the Course with this code, or raise LookupError.""" + course = session.exec(select(Course).where(Course.code == code)).first() + if course is None: + raise LookupError(f"no course with code={code!r}") + return course + + +def enroll( + session: Session, + *, + jaccount: str, + course_code: str, + role: Role = Role.student, +) -> Enrollment: + """Enroll a user in a course (idempotent — existing row returned as-is).""" + user = user_get(session, jaccount) + course = course_get(session, course_code) + existing = session.get(Enrollment, (user.id, course.id)) + if existing is not None: + return existing + e = Enrollment(user_id=user.id, course_id=course.id, role=role) + session.add(e) + session.commit() + session.refresh(e) + return e + + +def token_issue( + session: Session, *, jaccount: str, plaintext: str | None = None +) -> tuple[StudentToken, str]: + """Issue a fresh token for the user, revoking any active prior token. + + Returns the StudentToken row plus the *plaintext* — this is the only + time the plaintext is observable; the DB only ever sees sha256(plaintext). + """ + user = user_get(session, jaccount) + active = session.exec( + select(StudentToken) + .where(StudentToken.user_id == user.id) + .where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).first() + if active is not None: + active.revoked_at = utcnow() + session.add(active) + session.commit() + + if plaintext is None: + plaintext = "st_" + secrets.token_urlsafe(32) + digest = hashlib.sha256(plaintext.encode()).digest() + tok = StudentToken(user_id=user.id, token_hash=digest, prefix=plaintext[:8]) + session.add(tok) + session.commit() + session.refresh(tok) + return tok, plaintext + + +def token_revoke(session: Session, *, prefix: str) -> int: + """Revoke all active tokens whose stored prefix matches. Returns count.""" + rows = session.exec( + select(StudentToken) + .where(StudentToken.prefix == prefix) + .where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).all() + for r in rows: + r.revoked_at = utcnow() + session.add(r) + session.commit() + return len(rows) + + +def revoke_tokens_for_user(session: Session, jaccount: str) -> int: + """Revoke ALL active tokens for a user (by jaccount). Returns count.""" + user = user_get(session, jaccount) + rows = session.exec( + select(StudentToken) + .where(StudentToken.user_id == user.id) + .where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).all() + for r in rows: + r.revoked_at = utcnow() + session.add(r) + session.commit() + return len(rows) + + +# --- batch (CSV) operations --------------------------------------------- +# +# Each runs one roster row at a time, continues past per-row failures, and +# returns a list of result dicts: {"jaccount", "status", ...}. Status values: +# created/exists/enrolled/issued/revoked/error. The caller decides the exit +# code from whether any row is "error". + + +def batch_user_create( + session: Session, + rows: list[RosterRow], + *, + email_domain: str = DEFAULT_EMAIL_DOMAIN, +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + try: + if session.exec(select(User).where(User.jaccount == row.jaccount)).first(): + out.append({"jaccount": row.jaccount, "status": "exists"}) + continue + u = user_create( + session, + jaccount=row.jaccount, + email=derive_email(row.jaccount, email_domain), + display_name=row.name, + student_id=row.student_id, + ) + out.append({"jaccount": row.jaccount, "status": "created", "email": u.email}) + except Exception as e: # noqa: BLE001 - per-row isolation; report and continue + session.rollback() + out.append({"jaccount": row.jaccount, "status": "error", "error": str(e)}) + return out + + +def batch_enroll( + session: Session, + rows: list[RosterRow], + *, + course_code: str, + role: Role = Role.student, +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + try: + user = user_get(session, row.jaccount) + course = course_get(session, course_code) + if session.get(Enrollment, (user.id, course.id)) is not None: + out.append({"jaccount": row.jaccount, "status": "exists"}) + continue + enroll(session, jaccount=row.jaccount, course_code=course_code, role=role) + out.append({"jaccount": row.jaccount, "status": "enrolled"}) + except Exception as e: # noqa: BLE001 - per-row isolation; report and continue + session.rollback() + out.append({"jaccount": row.jaccount, "status": "error", "error": str(e)}) + return out + + +def batch_token_issue(session: Session, rows: list[RosterRow]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + try: + tok, plaintext = token_issue(session, jaccount=row.jaccount) + out.append( + { + "jaccount": row.jaccount, + "status": "issued", + "plaintext": plaintext, + "prefix": tok.prefix, + } + ) + except Exception as e: # noqa: BLE001 - per-row isolation; report and continue + session.rollback() + out.append({"jaccount": row.jaccount, "status": "error", "error": str(e)}) + return out + + +def batch_token_revoke(session: Session, rows: list[RosterRow]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in rows: + try: + n = revoke_tokens_for_user(session, row.jaccount) + out.append({"jaccount": row.jaccount, "status": "revoked", "revoked": n}) + except Exception as e: # noqa: BLE001 - per-row isolation; report and continue + session.rollback() + out.append({"jaccount": row.jaccount, "status": "error", "error": str(e)}) + return out + + +# --- output helpers ----------------------------------------------------- + + +def _print_json(obj: Any) -> None: + print(json.dumps(obj, default=str, indent=2)) + + +def _emit_batch(results: list[dict[str, Any]]) -> int: + """Print batch results and return a non-zero code if any row errored.""" + _print_json(results) + return 1 if any(r.get("status") == "error" for r in results) else 0 + + +# --- CLI command dispatchers -------------------------------------------- + + +def _cmd_user_create(session: Session, args: argparse.Namespace) -> int: + if args.csv: + return _emit_batch( + batch_user_create(session, read_roster(args.csv), email_domain=args.email_domain) + ) + if not (args.jaccount and args.name): + print("error: provide --csv, or both --jaccount and --name", file=sys.stderr) + return 2 + email = args.email or derive_email(args.jaccount, args.email_domain) + u = user_create( + session, + jaccount=args.jaccount, + email=email, + display_name=args.name, + student_id=args.student_id, + ) + _print_json( + { + "id": str(u.id), + "jaccount": u.jaccount, + "email": u.email, + "student_id": u.student_id, + } + ) + return 0 + + +def _cmd_user_list(session: Session, _args: argparse.Namespace) -> None: + rows = session.exec(select(User).order_by(User.created_at)).all() + _print_json( + [ + { + "id": str(u.id), + "jaccount": u.jaccount, + "email": u.email, + "active": u.is_active, + } + for u in rows + ] + ) + + +def _cmd_course_create(session: Session, args: argparse.Namespace) -> None: + c = course_create(session, code=args.code, title=args.title, semester=args.semester) + _print_json({"id": str(c.id), "code": c.code, "title": c.title, "semester": c.semester}) + + +def _cmd_course_list(session: Session, _args: argparse.Namespace) -> None: + rows = session.exec(select(Course).order_by(Course.created_at)).all() + _print_json( + [ + { + "id": str(c.id), + "code": c.code, + "title": c.title, + "semester": c.semester, + } + for c in rows + ] + ) + + +def _cmd_enroll(session: Session, args: argparse.Namespace) -> int: + role = Role(args.role) + if args.csv: + return _emit_batch( + batch_enroll(session, read_roster(args.csv), course_code=args.course, role=role) + ) + if not args.user: + print("error: provide --csv, or --user", file=sys.stderr) + return 2 + e = enroll(session, jaccount=args.user, course_code=args.course, role=role) + _print_json( + { + "user_id": str(e.user_id), + "course_id": str(e.course_id), + "role": e.role, + } + ) + return 0 + + +def _cmd_token_issue(session: Session, args: argparse.Namespace) -> int: + if args.csv: + return _emit_batch(batch_token_issue(session, read_roster(args.csv))) + if not args.user: + print("error: provide --csv, or --user", file=sys.stderr) + return 2 + tok, plaintext = token_issue(session, jaccount=args.user) + # Plaintext shown EXACTLY ONCE — student must save it now. + _print_json({"plaintext": plaintext, "prefix": tok.prefix, "id": str(tok.id)}) + return 0 + + +def _cmd_token_revoke(session: Session, args: argparse.Namespace) -> int: + if args.csv: + return _emit_batch(batch_token_revoke(session, read_roster(args.csv))) + if not args.prefix: + print("error: provide --csv, or --prefix", file=sys.stderr) + return 2 + n = token_revoke(session, prefix=args.prefix) + _print_json({"revoked": n}) + return 0 + + +def _cmd_token_list(session: Session, args: argparse.Namespace) -> None: + q = select(StudentToken) + if args.user: + user = user_get(session, args.user) + q = q.where(StudentToken.user_id == user.id) + rows = session.exec(q.order_by(StudentToken.created_at.desc())).all() + _print_json( + [ + { + "id": str(t.id), + "user_id": str(t.user_id), + "prefix": t.prefix, + "created_at": t.created_at.isoformat(), + "revoked_at": (t.revoked_at.isoformat() if t.revoked_at else None), + } + for t in rows + ] + ) + + +def _cmd_record_list(session: Session, args: argparse.Namespace) -> None: + # Reminder: blob_status=uploaded does NOT mean "this record's hash is + # currently verifiable on jbox" for non-latest turns of a session. + # See BlobStatus docstring in models.py. + q = select(ContextRecord).order_by(ContextRecord.ts.desc()).limit(args.limit) + if args.course: + c = course_get(session, args.course) + q = q.where(ContextRecord.course_id == c.id) + if args.user: + u = user_get(session, args.user) + q = q.where(ContextRecord.user_id == u.id) + if args.assignment: + q = q.where(ContextRecord.assignment == args.assignment) + if args.status: + q = q.where(ContextRecord.blob_status == BlobStatus(args.status)) + rows = session.exec(q).all() + _print_json( + [ + { + "id": str(r.id), + "ts": r.ts.isoformat(), + "user_id": str(r.user_id), + "course_id": str(r.course_id), + "assignment": r.assignment, + "model": r.model, + "blob_size": r.blob_size, + "blob_status": r.blob_status, + "blob_uri": r.blob_uri, + } + for r in rows + ] + ) + + +async def _cmd_record_payload(session: Session, args: argparse.Namespace) -> None: + """Fetch the blob from Tbox and verify its hash.""" + from aimdware_backend.jbox import JboxNotFound, default_reader + + rid = UUID(args.id) + record = session.get(ContextRecord, rid) + if record is None: + print(f"no record with id={args.id}", file=sys.stderr) + sys.exit(1) + reader = default_reader() + try: + bytes_ = await reader.get(record.blob_uri) + except JboxNotFound: + print(f"blob missing from jbox at {record.blob_uri}", file=sys.stderr) + sys.exit(2) + actual = hashlib.sha256(bytes_).digest() + verified = actual == record.blob_hash + _print_json( + { + "record_id": args.id, + "blob_uri": record.blob_uri, + "blob_size_stored": record.blob_size, + "blob_size_actual": len(bytes_), + "blob_hash_stored": record.blob_hash.hex(), + "blob_hash_actual": actual.hex(), + "verified": verified, + "payload_utf8": bytes_.decode("utf-8", errors="replace"), + } + ) + + +# --- argparse plumbing --------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="aimdware-admin", description="TT admin CLI for aimdware backend" + ) + sub = p.add_subparsers(dest="cmd", required=True) + + # user + p_user = sub.add_parser("user", help="manage users") + s_user = p_user.add_subparsers(dest="op", required=True) + p_uc = s_user.add_parser("create") + p_uc.add_argument("--jaccount", help="single-user mode") + p_uc.add_argument("--email", help="derived as @ if omitted") + p_uc.add_argument("--name") + p_uc.add_argument("--student-id", dest="student_id", default=None) + p_uc.add_argument("--csv", help="roster CSV: name,student_id,jaccount") + p_uc.add_argument("--email-domain", dest="email_domain", default=DEFAULT_EMAIL_DOMAIN) + p_uc.set_defaults(func=_cmd_user_create) + s_user.add_parser("list").set_defaults(func=_cmd_user_list) + + # course + p_course = sub.add_parser("course", help="manage courses") + s_course = p_course.add_subparsers(dest="op", required=True) + p_cc = s_course.add_parser("create") + p_cc.add_argument("--code", required=True) + p_cc.add_argument("--title", required=True) + p_cc.add_argument("--semester", required=True) + p_cc.set_defaults(func=_cmd_course_create) + s_course.add_parser("list").set_defaults(func=_cmd_course_list) + + # enroll + p_enr = sub.add_parser("enroll", help="enroll a user in a course") + p_enr.add_argument("--user", help="user jaccount (single mode)") + p_enr.add_argument("--course", required=True, help="course code") + p_enr.add_argument("--role", default="student", choices=["student", "admin"]) + p_enr.add_argument("--csv", help="roster CSV: name,student_id,jaccount") + p_enr.set_defaults(func=_cmd_enroll) + + # token + p_tok = sub.add_parser("token", help="manage student tokens") + s_tok = p_tok.add_subparsers(dest="op", required=True) + p_ti = s_tok.add_parser("issue") + p_ti.add_argument("--user", help="user jaccount (single mode)") + p_ti.add_argument("--csv", help="roster CSV: issue for each jaccount") + p_ti.set_defaults(func=_cmd_token_issue) + p_tr = s_tok.add_parser("revoke") + p_tr.add_argument("--prefix", help="8-char prefix shown when issued (single mode)") + p_tr.add_argument("--csv", help="roster CSV: revoke all active tokens per jaccount") + p_tr.set_defaults(func=_cmd_token_revoke) + p_tl = s_tok.add_parser("list") + p_tl.add_argument("--user", default=None) + p_tl.set_defaults(func=_cmd_token_list) + + # record + p_rec = sub.add_parser("record", help="inspect captured context records") + s_rec = p_rec.add_subparsers(dest="op", required=True) + p_rl = s_rec.add_parser("list") + p_rl.add_argument("--course", default=None) + p_rl.add_argument("--user", default=None) + p_rl.add_argument("--assignment", default=None) + p_rl.add_argument( + "--status", + default=None, + choices=[s.value for s in BlobStatus], + ) + p_rl.add_argument("--limit", type=int, default=50) + p_rl.set_defaults(func=_cmd_record_list) + p_rp = s_rec.add_parser("payload") + p_rp.add_argument("--id", required=True, help="ContextRecord UUID") + p_rp.set_defaults(func=_cmd_record_payload) + + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + engine = get_engine() + # Do NOT call SQLModel.metadata.create_all() — production schema is + # owned by Alembic, and create_all() can't express the partial unique + # index that enforces "one active token per user". If we silently + # create tables here, a later `alembic upgrade head` would fail with + # "table already exists". Refuse early with a clear message instead. + if not _schema_exists(engine): + print( + "error: database schema is missing or incomplete.\n" + " run `uv run alembic upgrade head` first.", + file=sys.stderr, + ) + return 2 + + with Session(engine) as session: + if inspect.iscoroutinefunction(args.func): + rc = asyncio.run(args.func(session, args)) + else: + rc = args.func(session, args) + return rc if isinstance(rc, int) else 0 + + +CURRENT_SCHEMA_REVISION = "c0a1d2e3f4b5" + + +def _schema_exists(engine) -> bool: # type: ignore[no-untyped-def] + """Probe for an Alembic-owned schema at the revision this CLI expects.""" + from sqlalchemy import inspect as sa_inspect + + try: + insp = sa_inspect(engine) + if not ( + insp.has_table("context_records") + and insp.has_table("users") + and insp.has_table("alembic_version") + ): + return False + with engine.connect() as conn: + version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() + return version == CURRENT_SCHEMA_REVISION + except Exception: + return False + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/src/aimdware_backend/auth.py b/backend/src/aimdware_backend/auth.py new file mode 100644 index 0000000..a07551f --- /dev/null +++ b/backend/src/aimdware_backend/auth.py @@ -0,0 +1,64 @@ +"""Token-hash bearer auth for /ingest/*.""" + +from __future__ import annotations + +import hashlib +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlmodel import Session, select + +from aimdware_backend.db import get_session +from aimdware_backend.models import StudentToken, User + + +def _hash_token(plaintext: str) -> bytes: + return hashlib.sha256(plaintext.encode("utf-8")).digest() + + +def _extract_bearer(authorization: str | None) -> str | None: + if not authorization: + return None + parts = authorization.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + return parts[1].strip() or None + + +def authenticate_student( + authorization: Annotated[str | None, Header()] = None, + session: Annotated[Session, Depends(get_session)] = ..., # type: ignore[assignment] +) -> User: + """Resolve the Bearer token to a User row. + + Hashes the plaintext, looks up an active StudentToken, returns the + associated User. Raises 401 on any miss (missing header, malformed + header, unknown token, revoked token). + """ + plaintext = _extract_bearer(authorization) + if plaintext is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="missing or malformed bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + digest = _hash_token(plaintext) + row = session.exec( + select(StudentToken) + .where(StudentToken.token_hash == digest) + .where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).first() + if row is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + user = session.get(User, row.user_id) + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="user inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user diff --git a/backend/src/aimdware_backend/db.py b/backend/src/aimdware_backend/db.py new file mode 100644 index 0000000..9d2ac0d --- /dev/null +++ b/backend/src/aimdware_backend/db.py @@ -0,0 +1,31 @@ +"""Database engine + session dependency.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from sqlalchemy import Engine +from sqlmodel import Session, create_engine + +from aimdware_backend.settings import settings + +_engine: Engine | None = None + + +def get_engine() -> Engine: + """Return the process-wide engine, creating it on first call.""" + global _engine + if _engine is None: + _engine = create_engine( + settings.database_url, + connect_args=( + {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} + ), + ) + return _engine + + +def get_session() -> Iterator[Session]: + """FastAPI dependency yielding a per-request session.""" + with Session(get_engine()) as session: + yield session diff --git a/backend/src/aimdware_backend/jbox.py b/backend/src/aimdware_backend/jbox.py new file mode 100644 index 0000000..0a676a8 --- /dev/null +++ b/backend/src/aimdware_backend/jbox.py @@ -0,0 +1,51 @@ +"""WebDAV-via-Tbox blob reader.""" + +from __future__ import annotations + +from typing import Protocol + +import httpx + + +class JboxReader(Protocol): + """Fetch a blob's bytes by its blob_uri. Pluggable for tests.""" + + async def get(self, blob_uri: str) -> bytes: ... + + +class JboxNotFound(Exception): + """Raised when the Tbox WebDAV responds 404 for the blob path.""" + + +class TboxWebDAVReader: + """Fetch blobs from a Tbox WebDAV endpoint (default: backend-side Tbox).""" + + def __init__( + self, + base_url: str, + timeout_s: float = 10.0, + auth: tuple[str, str] | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout_s + self._auth = auth + + async def get(self, blob_uri: str) -> bytes: + # blob_uri stored as a relative path (e.g. "aimdware/ECE4721J/.json"). + # Strip a leading slash so urljoin doesn't drop our base path. + path = blob_uri.lstrip("/") + url = f"{self._base_url}/{path}" + async with httpx.AsyncClient(timeout=self._timeout, auth=self._auth) as client: + response = await client.get(url) + if response.status_code == 404: + raise JboxNotFound(f"blob not found at {url}") + response.raise_for_status() + return response.content + + +def default_reader() -> JboxReader: + """Build the default reader from settings.""" + from aimdware_backend.settings import settings + + auth = (settings.tbox_user, settings.tbox_pass) if settings.tbox_user else None + return TboxWebDAVReader(settings.tbox_url, auth=auth) diff --git a/backend/src/aimdware_backend/main.py b/backend/src/aimdware_backend/main.py new file mode 100644 index 0000000..4289c8f --- /dev/null +++ b/backend/src/aimdware_backend/main.py @@ -0,0 +1,17 @@ +"""FastAPI app factory.""" + +from __future__ import annotations + +from fastapi import FastAPI + +from aimdware_backend.routes import admin, ingest + + +def create_app() -> FastAPI: + app = FastAPI(title="aimdware-backend") + app.include_router(ingest.router) + app.include_router(admin.router) + return app + + +app = create_app() diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py new file mode 100644 index 0000000..f9461a7 --- /dev/null +++ b/backend/src/aimdware_backend/models.py @@ -0,0 +1,126 @@ +"""SQLModel schemas for the aimdware backend.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, BigInteger, Column, Index, LargeBinary, UniqueConstraint +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + """Return the current UTC time (timezone-aware).""" + return datetime.now(UTC) + + +class Role(StrEnum): + student = "student" + admin = "admin" + + +class BlobStatus(StrEnum): + """Lifecycle of the per-record blob on jbox. + + Note on multi-turn sessions: the blob file is keyed by `session_id` + and overwritten on every turn. So `uploaded` on a record means + "the router successfully PUT a snapshot for this turn at the time" — + NOT "this record's `blob_hash` matches what's currently on jbox". + For all turns except the latest of their session, the on-jbox bytes + have since moved on; verification will report `verified=false` with + `is_latest_turn=false`. Use /admin/session//payload for the + canonical "verify the session's current blob" workflow. + """ + + pending = "pending" + uploaded = "uploaded" + verified = "verified" + tampered = "tampered" + missing = "missing" + + +class User(SQLModel, table=True): + __tablename__ = "users" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + display_name: str + email: str = Field(unique=True, index=True) + jaccount: str = Field(unique=True, index=True) + # Roster 学号. Nullable and NOT unique: imports may carry blanks, and we + # don't want a stray duplicate to fail the whole batch. jaccount remains + # the identity; student_id is carried for the TT's cross-referencing. + student_id: str | None = Field(default=None) + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=utcnow) + + +class Course(SQLModel, table=True): + __tablename__ = "courses" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + code: str = Field(unique=True, index=True) + title: str + semester: str + created_at: datetime = Field(default_factory=utcnow) + + +class Enrollment(SQLModel, table=True): + __tablename__ = "enrollments" + + user_id: UUID = Field(foreign_key="users.id", primary_key=True) + course_id: UUID = Field(foreign_key="courses.id", primary_key=True) + role: Role + created_at: datetime = Field(default_factory=utcnow) + + +class StudentToken(SQLModel, table=True): + __tablename__ = "student_tokens" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="users.id", index=True) + token_hash: bytes = Field(sa_column=Column(LargeBinary)) + prefix: str + created_at: datetime = Field(default_factory=utcnow) + revoked_at: datetime | None = None + + # Partial unique index — at most one active token per user — is added + # in the Alembic migration. SQLModel can't express partial uniqueness + # natively, and we don't want it shadowing the index below. + __table_args__ = (Index("ix_student_tokens_user", "user_id"),) + + +class ContextRecord(SQLModel, table=True): + __tablename__ = "context_records" + # Encode the invariant: turn_count is unique within a session. Two + # routers writing concurrent records for the same session can never + # silently collide on a tiebreaker; the DB will reject the duplicate. + __table_args__ = ( + UniqueConstraint("session_id", "turn_count", name="ux_context_records_session_turn"), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="users.id", index=True) + course_id: UUID = Field(foreign_key="courses.id", index=True) + # Free-form assignment label scoped to the course. The TT decides + # what this means — homework slug, lab number, exam name, etc. + # Captured so audit can filter "what did this student do for hw3". + # Not a FK because we don't model assignments as first-class entities. + assignment: str = Field(index=True) + # Session this record belongs to. Multiple records can share a session + # (each agent turn = one record, all sharing one session_id and one + # blob_uri). New session for one-off chats too — they're a session of 1. + session_id: UUID = Field(index=True) + turn_count: int = Field(default=1) + ts: datetime = Field(default_factory=utcnow, index=True) + model: str + prompt_tokens: int = 0 + completion_tokens: int = 0 + router_version: str + client_meta: dict = Field(default_factory=dict, sa_column=Column(JSON)) + + blob_uri: str + blob_hash: bytes = Field(sa_column=Column(LargeBinary)) + blob_size: int = Field(sa_column=Column(BigInteger)) + blob_status: BlobStatus = Field(default=BlobStatus.pending, index=True) + blob_verified_at: datetime | None = None diff --git a/backend/src/aimdware_backend/roster.py b/backend/src/aimdware_backend/roster.py new file mode 100644 index 0000000..64d0d23 --- /dev/null +++ b/backend/src/aimdware_backend/roster.py @@ -0,0 +1,42 @@ +"""Parse a roster CSV: columns `name, student_id, jaccount`. + +Used by the admin CLI's `--csv` batch modes. UTF-8 (BOM tolerated), an +optional header row is skipped, blank lines are skipped, and `jaccount` is +the only strictly-required cell. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RosterRow: + name: str + student_id: str | None + jaccount: str + + +def read_roster(path: str) -> list[RosterRow]: + """Read a roster file into rows. Raises ValueError on a malformed row.""" + rows: list[RosterRow] = [] + # utf-8-sig strips a leading BOM if present (Excel-exported CSVs have one). + with open(path, encoding="utf-8-sig", newline="") as f: + for fields in csv.reader(f): + cells = [c.strip() for c in fields] + if not any(cells): # blank line + continue + if len(cells) < 3: + raise ValueError( + f"roster row needs 3 columns (name,student_id,jaccount): {fields!r}" + ) + name, student_id, jaccount = cells[0], cells[1], cells[2] + # A header row (literal 'jaccount' in the jaccount column) is skipped + # so the TT can keep the friendly `名字,学号,jaccount` header. + if jaccount.lower() == "jaccount": + continue + if not jaccount: + raise ValueError(f"roster row is missing jaccount: {fields!r}") + rows.append(RosterRow(name=name, student_id=student_id or None, jaccount=jaccount)) + return rows diff --git a/backend/src/aimdware_backend/routes/__init__.py b/backend/src/aimdware_backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/aimdware_backend/routes/admin.py b/backend/src/aimdware_backend/routes/admin.py new file mode 100644 index 0000000..0a715ad --- /dev/null +++ b/backend/src/aimdware_backend/routes/admin.py @@ -0,0 +1,123 @@ +"""Admin endpoints — TT-facing, shared-secret bearer auth.""" + +from __future__ import annotations + +import hashlib +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from aimdware_backend.admin_auth import authenticate_admin +from aimdware_backend.db import get_session +from aimdware_backend.jbox import JboxNotFound, JboxReader, default_reader +from aimdware_backend.models import ContextRecord + +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(authenticate_admin)], +) + + +def get_jbox_reader() -> JboxReader: + """Default jbox reader; overridable in tests via dependency_overrides.""" + return default_reader() + + +def _latest_record_for_session(session: Session, session_id: UUID) -> ContextRecord | None: + # turn_count is monotonically increasing within a session (enforced by + # a UNIQUE(session_id, turn_count) DB constraint), but we still tiebreak + # on ts.desc() defensively. If two records ever did share a turn_count, + # the latest-by-wall-clock is the one we want for verification. + return session.exec( + select(ContextRecord) + .where(ContextRecord.session_id == session_id) + .order_by( + ContextRecord.turn_count.desc(), # type: ignore[union-attr] + ContextRecord.ts.desc(), # type: ignore[union-attr] + ) + ).first() + + +async def _fetch_and_verify(record: ContextRecord, reader: JboxReader) -> tuple[bytes, bool]: + try: + bytes_ = await reader.get(record.blob_uri) + except JboxNotFound: + raise HTTPException(status_code=404, detail="blob missing from jbox") from None + except Exception as exc: + raise HTTPException(status_code=502, detail=f"jbox fetch failed: {exc}") from None + actual = hashlib.sha256(bytes_).digest() + return bytes_, actual == record.blob_hash + + +@router.get("/context/{record_id}/payload") +async def get_payload( + record_id: UUID, + session: Annotated[Session, Depends(get_session)], + reader: Annotated[JboxReader, Depends(get_jbox_reader)], +) -> dict[str, Any]: + """Fetch the captured payload from jbox and verify its hash. + + Note for sessions with multiple turns: the blob on jbox is the + *latest* turn's snapshot. A `record_id` that isn't the latest turn + will show `is_latest_turn=false` and `verified=false` — its stored + hash represents the snapshot at the time of THAT turn, which no + longer exists on jbox. For session-level verification call + `/admin/session//payload`. + """ + record = session.get(ContextRecord, record_id) + if record is None: + raise HTTPException(status_code=404, detail="record not found") + + latest = _latest_record_for_session(session, record.session_id) + is_latest_turn = latest is not None and latest.id == record.id + + bytes_, verified = await _fetch_and_verify(record, reader) + actual_hex = hashlib.sha256(bytes_).hexdigest() + return { + "record_id": str(record.id), + "session_id": str(record.session_id), + "turn_count": record.turn_count, + "is_latest_turn": is_latest_turn, + "blob_uri": record.blob_uri, + "blob_size_stored": record.blob_size, + "blob_size_actual": len(bytes_), + "blob_hash_stored": record.blob_hash.hex(), + "blob_hash_actual": actual_hex, + "verified": verified, + "payload_utf8": bytes_.decode("utf-8", errors="replace"), + } + + +@router.get("/session/{session_id}/payload") +async def get_session_payload( + session_id: UUID, + session: Annotated[Session, Depends(get_session)], + reader: Annotated[JboxReader, Depends(get_jbox_reader)], +) -> dict[str, Any]: + """Fetch the session's current blob from jbox, verify against the + latest turn's stored hash. + + The blob is overwritten on each turn, so its hash matches the latest + turn's `blob_hash` (and only that one). + """ + latest = _latest_record_for_session(session, session_id) + if latest is None: + raise HTTPException(status_code=404, detail="session not found") + + bytes_, verified = await _fetch_and_verify(latest, reader) + actual_hex = hashlib.sha256(bytes_).hexdigest() + return { + "session_id": str(session_id), + "latest_record_id": str(latest.id), + "turn_count": latest.turn_count, + "blob_uri": latest.blob_uri, + "blob_size_stored": latest.blob_size, + "blob_size_actual": len(bytes_), + "blob_hash_stored": latest.blob_hash.hex(), + "blob_hash_actual": actual_hex, + "verified": verified, + "payload_utf8": bytes_.decode("utf-8", errors="replace"), + } diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py new file mode 100644 index 0000000..a9616f3 --- /dev/null +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -0,0 +1,215 @@ +"""Ingest API — the only HTTP surface the student router talks to.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from aimdware_backend.auth import authenticate_student +from aimdware_backend.db import get_session +from aimdware_backend.models import ( + BlobStatus, + ContextRecord, + Course, + Enrollment, + Role, + User, +) + +router = APIRouter(prefix="/ingest", tags=["ingest"]) + + +class IngestContextBody(BaseModel): + """Body of POST /ingest/context — metadata + hash only, no blob bytes.""" + + model_config = ConfigDict(extra="forbid") + + record_id: UUID + session_id: UUID + turn_count: int = Field(ge=1, default=1) + course_code: str + # Free-form course-scoped label. Stricter than blob_uri's path component + # because we use it for filtering / display, not for path composition. + assignment: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.\-]+$") + # Hex-encoded sha256 — 64 chars, [0-9a-fA-F] only. Rejecting non-hex / + # wrong-length here means a buggy router gets 422 (fatal, not retryable) + # instead of an infinite 500 retry loop. + blob_hash: str = Field(pattern=r"^[0-9a-fA-F]{64}$") + # Path under the WebDAV endpoint. We enforce the canonical shape + # `aimdware///.json` so a malicious router + # can't smuggle `..` / absolute URLs / weird chars that the admin + # payload-fetch path would later happily resolve. + blob_uri: str = Field( + pattern=r"^aimdware/[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+/" + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.json$", + ) + blob_size: int = Field(ge=0) + # Model + token counts are best-effort metadata. The router does not + # parse the captured response, so these are optional and may be empty + # for v1. Backend can re-derive them by reading the blob from jbox. + model: str = "" + prompt_tokens: int = 0 + completion_tokens: int = 0 + ts: datetime + router_version: str + client_meta: dict[str, Any] = Field(default_factory=dict) + + +def _ts_equal(a: datetime, b: datetime) -> bool: + """Compare two datetimes robustly across tz-naive (SQLite stores + naive UTC) and tz-aware (Pydantic parses ISO strings to tz-aware UTC). + Both sides are normalised to naive UTC for the comparison.""" + if a is None or b is None: + return a is b + if a.tzinfo is not None: + a = a.astimezone(UTC).replace(tzinfo=None) + if b.tzinfo is not None: + b = b.astimezone(UTC).replace(tzinfo=None) + return a == b + + +def _resolve_enrolled_course(session: Session, user: User, course_code: str) -> Course: + course = session.exec(select(Course).where(Course.code == course_code)).first() + if course is None: + raise HTTPException(status_code=404, detail="course not found") + enrol = session.exec( + select(Enrollment).where( + Enrollment.user_id == user.id, + Enrollment.course_id == course.id, + Enrollment.role == Role.student, + ) + ).first() + if enrol is None: + raise HTTPException(status_code=403, detail="not enrolled as student in this course") + return course + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@router.post("/context") +def post_context( + body: IngestContextBody, + response: Response, + user: Annotated[User, Depends(authenticate_student)], + session: Annotated[Session, Depends(get_session)], +) -> dict[str, str]: + """Record a context entry. + + Idempotent on `record_id`: a replay with matching body returns 200; + matching id with different body returns 409; a fresh insert returns 202. + """ + course = _resolve_enrolled_course(session, user, body.course_code) + expected_blob_uri = f"aimdware/{body.course_code}/{body.assignment}/{body.session_id}.json" + if body.blob_uri != expected_blob_uri: + raise HTTPException( + status_code=422, + detail="blob_uri must match course_code, assignment, and session_id", + ) + + digest = bytes.fromhex(body.blob_hash) + existing = session.get(ContextRecord, body.record_id) + if existing is not None: + # Two-phase compare so the diagnostic message can name the + # mismatching field. The mandatory fields are everything that + # would make the existing row semantically different from the + # incoming replay; we deliberately do NOT compare `client_meta`, + # `prompt_tokens`, `completion_tokens` which can legitimately drift. + mismatches = [ + name + for name, ok in ( + ("blob_hash", existing.blob_hash == digest), + ("blob_uri", existing.blob_uri == body.blob_uri), + ("blob_size", existing.blob_size == body.blob_size), + ("user_id", existing.user_id == user.id), + ("course_id", existing.course_id == course.id), + ("assignment", existing.assignment == body.assignment), + ("session_id", existing.session_id == body.session_id), + ("turn_count", existing.turn_count == body.turn_count), + ("ts", _ts_equal(existing.ts, body.ts)), + ("model", existing.model == body.model), + ("router_version", existing.router_version == body.router_version), + ) + if not ok + ] + if mismatches: + raise HTTPException( + status_code=409, + detail=( + "record_id matches existing row but these fields differ: " + + ", ".join(mismatches) + ), + ) + response.status_code = status.HTTP_200_OK + return {"id": str(existing.id), "status": "exists"} + + record = ContextRecord( + id=body.record_id, + user_id=user.id, + course_id=course.id, + assignment=body.assignment, + session_id=body.session_id, + turn_count=body.turn_count, + ts=body.ts, + model=body.model, + prompt_tokens=body.prompt_tokens, + completion_tokens=body.completion_tokens, + router_version=body.router_version, + client_meta=body.client_meta, + blob_uri=body.blob_uri, + blob_hash=digest, + blob_size=body.blob_size, + ) + session.add(record) + try: + session.commit() + except IntegrityError: + # The only constraint that can fire here (record_id was checked above) + # is UNIQUE(session_id, turn_count). Two routers racing on the same + # session/turn with different record_ids land here. + session.rollback() + raise HTTPException( + status_code=409, + detail=( + "duplicate (session_id, turn_count): another record already " + "claims this turn of this session" + ), + ) from None + response.status_code = status.HTTP_202_ACCEPTED + return {"id": str(record.id), "status": "created"} + + +@router.post("/context/{record_id}/uploaded", status_code=200) +def mark_uploaded( + record_id: UUID, + user: Annotated[User, Depends(authenticate_student)], + session: Annotated[Session, Depends(get_session)], +) -> dict[str, str]: + """Mark blob_status uploaded once the router confirms WebDAV PUT. + + Scoped to the caller — only the owning student can mark their own + records. Idempotent: a second call on an already-uploaded record is + a no-op. + + For multi-turn sessions: every turn calls this endpoint, but the + jbox file is shared and gets overwritten on each turn. So an older + turn's record can have blob_status=uploaded yet still fail + /admin/context//payload verification (its `blob_hash` describes + a snapshot that no longer exists on jbox). See BlobStatus docstring. + """ + record = session.get(ContextRecord, record_id) + if record is None or record.user_id != user.id: + raise HTTPException(status_code=404, detail="record not found") + if record.blob_status == BlobStatus.pending: + record.blob_status = BlobStatus.uploaded + session.add(record) + session.commit() + return {"id": str(record.id), "status": record.blob_status.value} diff --git a/backend/src/aimdware_backend/settings.py b/backend/src/aimdware_backend/settings.py new file mode 100644 index 0000000..7c4c230 --- /dev/null +++ b/backend/src/aimdware_backend/settings.py @@ -0,0 +1,34 @@ +"""Process-wide settings, populated from env vars.""" + +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # Values come from process env vars (AIMDWARE_*) and, if present, a `.env` + # file in the working directory. Real env vars take precedence over `.env`. + model_config = SettingsConfigDict( + env_prefix="AIMDWARE_", + case_sensitive=False, + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + database_url: str = "sqlite:///./aimdware.db" + + # Shared secret for /admin/* endpoints. If empty, admin endpoints + # respond 503 (disabled). Set via $AIMDWARE_ADMIN_SECRET. + admin_secret: str = "" + + # WebDAV (Tbox) the backend should fetch student payloads from. + # In a production deploy this points at a Tbox instance bound to the + # TT-side jaccount that has been granted read permission on student + # folders. + tbox_url: str = "http://127.0.0.1:8089" + tbox_user: str = "" + tbox_pass: str = "" + + +settings = Settings() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..55da710 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,48 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import Engine, text +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +# Importing models registers them with SQLModel.metadata so create_all +# sees every table even if a test only uses one of them. +from aimdware_backend import models # noqa: F401 + + +@pytest.fixture +def engine() -> Engine: + """Fresh in-memory SQLite for each test. + + Uses StaticPool so the single in-memory database is shared across all + Session() calls on the engine (required for tests that read in one + session what another session wrote). + """ + e = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(e) + # SQLModel.metadata can't express the partial unique index that + # enforces "one active token per user" — that DDL lives only in + # Alembic 0001. Mirror it here so tests see the same constraints as + # production. Without this, tests pass vacuously on token-uniqueness + # invariants that prod would reject. + with e.begin() as conn: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS " + "ux_student_tokens_active_per_user " + "ON student_tokens (user_id) WHERE revoked_at IS NULL" + ) + ) + return e + + +@pytest.fixture +def session(engine: Engine): + with Session(engine) as s: + yield s diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..bb4c989 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,266 @@ +"""TDD: /admin/context/{id}/payload.""" + +from __future__ import annotations + +import hashlib +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import Engine +from sqlmodel import Session + +from aimdware_backend.db import get_session +from aimdware_backend.jbox import JboxNotFound, JboxReader +from aimdware_backend.main import create_app +from aimdware_backend.models import ContextRecord, Course, User +from aimdware_backend.routes.admin import get_jbox_reader +from aimdware_backend.settings import settings + + +@pytest.fixture(autouse=True) +def _enable_admin_secret(monkeypatch: pytest.MonkeyPatch) -> None: + """Default to a known admin secret for these tests.""" + monkeypatch.setattr(settings, "admin_secret", "test-admin-secret-xyz") + + +def _make_client(engine: Engine, reader: JboxReader) -> TestClient: + app = create_app() + + def override_session(): + with Session(engine) as s: + yield s + + app.dependency_overrides[get_session] = override_session + app.dependency_overrides[get_jbox_reader] = lambda: reader + return TestClient(app) + + +def _seed_record(session: Session, payload: bytes) -> ContextRecord: + from uuid import uuid4 + + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="ECE4721J", title="t", semester="s") + session.add_all([user, course]) + session.commit() + rec = ContextRecord( + user_id=user.id, + course_id=course.id, + assignment="hw1", + session_id=uuid4(), + turn_count=1, + model="gpt-4o-mini", + router_version="0.0.0", + blob_uri="aimdware/ECE4721J/hw1/sample.json", + blob_hash=hashlib.sha256(payload).digest(), + blob_size=len(payload), + ) + session.add(rec) + session.commit() + session.refresh(rec) + return rec + + +class _StaticReader: + def __init__(self, payload: bytes | None) -> None: + self.payload = payload + self.last_uri: str | None = None + + async def get(self, blob_uri: str) -> bytes: + self.last_uri = blob_uri + if self.payload is None: + raise JboxNotFound(blob_uri) + return self.payload + + +class _ErrorReader: + async def get(self, blob_uri: str) -> bytes: + raise RuntimeError("upstream Tbox timed out") + + +def _auth(secret: str = "test-admin-secret-xyz") -> dict[str, str]: + return {"Authorization": f"Bearer {secret}"} + + +# --- auth --- + + +def test_missing_auth_is_401(engine: Engine, session: Session) -> None: + payload = b'{"hello":"world"}' + rec = _seed_record(session, payload) + client = _make_client(engine, _StaticReader(payload)) + r = client.get(f"/admin/context/{rec.id}/payload") + assert r.status_code == 401 + + +def test_wrong_secret_is_401(engine: Engine, session: Session) -> None: + payload = b'{"x":1}' + rec = _seed_record(session, payload) + client = _make_client(engine, _StaticReader(payload)) + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth("wrong-secret")) + assert r.status_code == 401 + + +def test_admin_disabled_when_secret_unset( + engine: Engine, session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = b'{"x":1}' + rec = _seed_record(session, payload) + monkeypatch.setattr(settings, "admin_secret", "") + client = _make_client(engine, _StaticReader(payload)) + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + assert r.status_code == 503 + + +# --- happy path / verify --- + + +def test_payload_returns_verified_true_when_hash_matches(engine: Engine, session: Session) -> None: + payload = b'{"request_text": "...", "response_text": "...", "ts": "..."}' + rec = _seed_record(session, payload) + reader = _StaticReader(payload) + client = _make_client(engine, reader) + + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + assert r.status_code == 200 + body = r.json() + assert body["verified"] is True + assert body["record_id"] == str(rec.id) + assert body["blob_uri"] == rec.blob_uri + assert body["blob_size_stored"] == len(payload) + assert body["blob_size_actual"] == len(payload) + assert body["blob_hash_stored"] == body["blob_hash_actual"] + assert body["payload_utf8"] == payload.decode("utf-8") + assert reader.last_uri == rec.blob_uri + + +def test_payload_returns_verified_false_when_hash_mismatches( + engine: Engine, session: Session +) -> None: + real_payload = b"original-bytes" + rec = _seed_record(session, real_payload) + tampered = b"tampered-bytes" + client = _make_client(engine, _StaticReader(tampered)) + + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + assert r.status_code == 200 + body = r.json() + assert body["verified"] is False + assert body["blob_hash_stored"] != body["blob_hash_actual"] + assert body["payload_utf8"] == tampered.decode("utf-8") + + +def test_payload_returns_404_when_record_unknown(engine: Engine, session: Session) -> None: + client = _make_client(engine, _StaticReader(b"x")) + r = client.get(f"/admin/context/{uuid4()}/payload", headers=_auth()) + assert r.status_code == 404 + + +def test_payload_returns_404_when_jbox_missing(engine: Engine, session: Session) -> None: + rec = _seed_record(session, b"x") + client = _make_client(engine, _StaticReader(None)) # signals NotFound + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + assert r.status_code == 404 + + +def test_payload_returns_502_when_jbox_errors(engine: Engine, session: Session) -> None: + rec = _seed_record(session, b"x") + client = _make_client(engine, _ErrorReader()) + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + assert r.status_code == 502 + + +def test_payload_endpoint_does_not_mutate_status(engine: Engine, session: Session) -> None: + """Reading the payload is non-destructive — no DB writes.""" + payload = b'{"x":1}' + rec = _seed_record(session, payload) + original_status = rec.blob_status + + # Even with mismatched bytes, status must not change. + client = _make_client(engine, _StaticReader(b"tampered")) + client.get(f"/admin/context/{rec.id}/payload", headers=_auth()) + + session.refresh(rec) + assert rec.blob_status == original_status + + +# ---------- session-level payload endpoint ---------- + + +def _seed_session(session: Session, *, turns: list[bytes]) -> tuple[list[ContextRecord], bytes]: + """Seed a session with `len(turns)` records. The session's "current blob" + on jbox is the last entry of `turns` — that's what _make_client's reader + should return. Each record's stored blob_hash is the sha256 of its + corresponding turn payload (i.e. what was hashed at capture time).""" + from uuid import uuid4 + + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="ECE4721J", title="t", semester="s") + session.add_all([user, course]) + session.commit() + sess_id = uuid4() + records: list[ContextRecord] = [] + for i, payload in enumerate(turns, start=1): + rec = ContextRecord( + user_id=user.id, + course_id=course.id, + assignment="hw1", + session_id=sess_id, + turn_count=i, + model="gpt-4o-mini", + router_version="0.0.0", + blob_uri=f"aimdware/ECE4721J/hw1/{sess_id}.json", + blob_hash=hashlib.sha256(payload).digest(), + blob_size=len(payload), + ) + session.add(rec) + session.commit() + session.refresh(rec) + records.append(rec) + return records, turns[-1] + + +def test_session_payload_verifies_against_latest_turn_hash( + engine: Engine, session: Session +) -> None: + turns = [b'{"turn":1}', b'{"turn":2}', b'{"turn":3}'] + records, current_on_jbox = _seed_session(session, turns=turns) + sess_id = records[0].session_id + + client = _make_client(engine, _StaticReader(current_on_jbox)) + r = client.get(f"/admin/session/{sess_id}/payload", headers=_auth()) + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == str(sess_id) + assert body["turn_count"] == 3 + assert body["latest_record_id"] == str(records[-1].id) + assert body["verified"] is True + assert body["payload_utf8"] == current_on_jbox.decode() + + +def test_session_payload_404_when_session_unknown(engine: Engine, session: Session) -> None: + client = _make_client(engine, _StaticReader(b"x")) + r = client.get(f"/admin/session/{uuid4()}/payload", headers=_auth()) + assert r.status_code == 404 + + +def test_context_payload_marks_non_latest_turn(engine: Engine, session: Session) -> None: + """Pulling an OLD turn's record shows is_latest_turn=False, and + verified=False because the on-jbox blob has moved on to a later turn.""" + turns = [b'{"turn":1}', b'{"turn":2}'] + records, current_on_jbox = _seed_session(session, turns=turns) + + client = _make_client(engine, _StaticReader(current_on_jbox)) + + # Fetch the FIRST turn's record_id but the file on jbox holds turn 2. + r = client.get(f"/admin/context/{records[0].id}/payload", headers=_auth()) + assert r.status_code == 200 + body = r.json() + assert body["is_latest_turn"] is False + assert body["verified"] is False # turn-1 hash != turn-2 on jbox + + # The LATEST turn does verify. + r2 = client.get(f"/admin/context/{records[-1].id}/payload", headers=_auth()) + body2 = r2.json() + assert body2["is_latest_turn"] is True + assert body2["verified"] is True diff --git a/backend/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py new file mode 100644 index 0000000..8d21760 --- /dev/null +++ b/backend/tests/test_admin_cli.py @@ -0,0 +1,235 @@ +"""Tests for the TT admin CLI's importable command functions.""" + +from __future__ import annotations + +import hashlib + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from aimdware_backend import settings +from aimdware_backend.admin_cli import ( + _schema_exists, + batch_enroll, + batch_token_issue, + batch_token_revoke, + batch_user_create, + course_create, + derive_email, + enroll, + revoke_tokens_for_user, + token_issue, + token_revoke, + user_create, + user_get, +) +from aimdware_backend.models import Course, Enrollment, Role, StudentToken, User +from aimdware_backend.roster import RosterRow + + +def test_user_create_adds_row(session: Session) -> None: + u = user_create(session, jaccount="alice", email="alice@sjtu.edu.cn", display_name="Alice") + fetched = session.exec(select(User).where(User.jaccount == "alice")).first() + assert fetched is not None + assert fetched.id == u.id + assert fetched.email == "alice@sjtu.edu.cn" + assert fetched.is_active is True + + +def test_user_get_raises_when_missing(session: Session) -> None: + import pytest + + with pytest.raises(LookupError): + user_get(session, "nobody") + + +def test_course_create_adds_row(session: Session) -> None: + c = course_create(session, code="VE477", title="Algorithms", semester="2026-fall") + fetched = session.exec(select(Course).where(Course.code == "VE477")).first() + assert fetched is not None + assert fetched.id == c.id + + +def test_enroll_links_user_and_course(session: Session) -> None: + user_create(session, jaccount="bob", email="bob@sjtu.edu.cn", display_name="Bob") + course_create(session, code="ECE4721J", title="Systems", semester="2026-spring") + e = enroll(session, jaccount="bob", course_code="ECE4721J", role=Role.student) + assert e.role == Role.student + rows = session.exec(select(Enrollment)).all() + assert len(rows) == 1 + + +def test_enroll_is_idempotent(session: Session) -> None: + user_create(session, jaccount="carol", email="carol@sjtu.edu.cn", display_name="Carol") + course_create(session, code="ECE4721J", title="Systems", semester="2026-spring") + enroll(session, jaccount="carol", course_code="ECE4721J") + enroll(session, jaccount="carol", course_code="ECE4721J") + assert len(session.exec(select(Enrollment)).all()) == 1 + + +def test_token_issue_stores_sha256_hash_and_returns_plaintext( + session: Session, +) -> None: + user_create(session, jaccount="dave", email="dave@sjtu.edu.cn", display_name="Dave") + tok, plaintext = token_issue(session, jaccount="dave") + assert tok.token_hash == hashlib.sha256(plaintext.encode()).digest() + assert tok.prefix == plaintext[:8] + assert tok.revoked_at is None + + +def test_token_issue_revokes_prior_active_token(session: Session) -> None: + user_create(session, jaccount="eve", email="eve@sjtu.edu.cn", display_name="Eve") + old, _ = token_issue(session, jaccount="eve") + new, _ = token_issue(session, jaccount="eve") + session.refresh(old) + assert old.revoked_at is not None + assert new.revoked_at is None + # Only one active token at a time. + active = session.exec( + select(StudentToken).where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).all() + assert len(active) == 1 + + +def test_token_revoke_by_prefix(session: Session) -> None: + user_create(session, jaccount="frank", email="frank@sjtu.edu.cn", display_name="Frank") + tok, _ = token_issue(session, jaccount="frank") + n = token_revoke(session, prefix=tok.prefix) + assert n == 1 + session.refresh(tok) + assert tok.revoked_at is not None + + +def test_token_revoke_no_match_returns_zero(session: Session) -> None: + assert token_revoke(session, prefix="missing!") == 0 + + +def test_main_refuses_when_schema_missing(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + """CLI must NOT silently create tables. On an empty DB it should + exit with a clear message pointing the operator at Alembic.""" + import os + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + from aimdware_backend import db as _db + + previous_engine = _db._engine # noqa: SLF001 - test must isolate process-wide engine + try: + monkeypatch.setattr(settings.settings, "database_url", f"sqlite:///{db_path}") + _db._engine = None # noqa: SLF001 - reset process-wide engine for this CLI test + from aimdware_backend.admin_cli import main as cli_main + + rc = cli_main(["user", "list"]) + assert rc == 2 + captured = capsys.readouterr() + assert "alembic upgrade head" in captured.err + finally: + created_engine = _db._engine # noqa: SLF001 - close temporary test engine before unlink + if created_engine is not None and created_engine is not previous_engine: + created_engine.dispose() + _db._engine = previous_engine # noqa: SLF001 - restore shared engine after CLI test + os.unlink(db_path) + + +def test_schema_probe_rejects_legacy_create_all_schema() -> None: + """A metadata.create_all DB has core tables but is not an Alembic-owned + production schema, so the CLI must refuse it.""" + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) + assert _schema_exists(engine) is False + + +# --- student_id + email derivation --------------------------------------- + + +def test_user_create_stores_student_id(session: Session) -> None: + u = user_create(session, jaccount="x", email="x@e", display_name="X", student_id="5190100009") + assert u.student_id == "5190100009" + + +def test_user_create_student_id_defaults_none(session: Session) -> None: + u = user_create(session, jaccount="y", email="y@e", display_name="Y") + assert u.student_id is None + + +def test_derive_email_from_jaccount() -> None: + assert derive_email("alice") == "alice@sjtu.edu.cn" + assert derive_email("bob", domain="example.edu") == "bob@example.edu" + + +# --- batch (CSV) operations ----------------------------------------------- + + +def test_batch_user_create_inserts_with_derived_email_and_student_id( + session: Session, +) -> None: + rows = [ + RosterRow(name="张三", student_id="5190100001", jaccount="zhangsan"), + RosterRow(name="李四", student_id="5190100002", jaccount="lisi"), + ] + res = batch_user_create(session, rows) + assert [r["status"] for r in res] == ["created", "created"] + z = user_get(session, "zhangsan") + assert z.email == "zhangsan@sjtu.edu.cn" + assert z.student_id == "5190100001" + assert z.display_name == "张三" + + +def test_batch_user_create_existing_jaccount_is_exists_not_error( + session: Session, +) -> None: + user_create(session, jaccount="dup", email="dup@sjtu.edu.cn", display_name="Dup") + res = batch_user_create(session, [RosterRow(name="Dup", student_id="5", jaccount="dup")]) + assert res[0]["status"] == "exists" + + +def test_batch_enroll_enrolls_then_reports_exists(session: Session) -> None: + course_create(session, code="C1", title="t", semester="s") + user_create(session, jaccount="a1", email="a1@e", display_name="A1") + row = RosterRow(name="A1", student_id="5", jaccount="a1") + assert ( + batch_enroll(session, [row], course_code="C1", role=Role.student)[0]["status"] == "enrolled" + ) + assert ( + batch_enroll(session, [row], course_code="C1", role=Role.student)[0]["status"] == "exists" + ) + + +def test_batch_enroll_missing_user_is_error(session: Session) -> None: + course_create(session, code="C2", title="t", semester="s") + res = batch_enroll( + session, + [RosterRow(name="Ghost", student_id="5", jaccount="ghost")], + course_code="C2", + role=Role.student, + ) + assert res[0]["status"] == "error" + assert "error" in res[0] + + +def test_batch_token_issue_returns_plaintext_and_prefix(session: Session) -> None: + user_create(session, jaccount="t1", email="t1@e", display_name="T1") + res = batch_token_issue(session, [RosterRow(name="T1", student_id="5", jaccount="t1")]) + assert res[0]["status"] == "issued" + assert res[0]["plaintext"].startswith("st_") + assert res[0]["prefix"] == res[0]["plaintext"][:8] + + +def test_revoke_tokens_for_user_revokes_all_active(session: Session) -> None: + user_create(session, jaccount="r1", email="r1@e", display_name="R1") + token_issue(session, jaccount="r1") + n = revoke_tokens_for_user(session, "r1") + assert n == 1 + active = session.exec( + select(StudentToken).where(StudentToken.revoked_at.is_(None)) # type: ignore[union-attr] + ).all() + assert active == [] + + +def test_batch_token_revoke_reports_count(session: Session) -> None: + user_create(session, jaccount="r2", email="r2@e", display_name="R2") + token_issue(session, jaccount="r2") + res = batch_token_revoke(session, [RosterRow(name="R2", student_id="5", jaccount="r2")]) + assert res[0]["status"] == "revoked" + assert res[0]["revoked"] == 1 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..77744ee --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,134 @@ +"""TDD: token-hash bearer auth.""" + +from __future__ import annotations + +import hashlib +from datetime import UTC +from typing import Annotated + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import Engine +from sqlmodel import Session + +from aimdware_backend.auth import authenticate_student +from aimdware_backend.db import get_session +from aimdware_backend.models import StudentToken, User + + +def _hash(plaintext: str) -> bytes: + return hashlib.sha256(plaintext.encode()).digest() + + +@pytest.fixture +def app(engine: Engine): + a = FastAPI() + + @a.get("/me") + def me(user: Annotated[User, Depends(authenticate_student)]): # noqa: ANN201 + return {"jaccount": user.jaccount} + + def override_session(): + with Session(engine) as s: + yield s + + a.dependency_overrides[get_session] = override_session + return a + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +def _seed_user_with_token(session: Session, plaintext: str) -> User: + user = User(display_name="A", email=f"{plaintext}@x", jaccount=plaintext[:32]) + session.add(user) + session.commit() + session.add(StudentToken(user_id=user.id, token_hash=_hash(plaintext), prefix=plaintext[:8])) + session.commit() + return user + + +def test_valid_bearer_token_authorizes(client: TestClient, session: Session) -> None: + plaintext = "st_GOOD_TOKEN_001" + user = _seed_user_with_token(session, plaintext) + r = client.get("/me", headers={"Authorization": f"Bearer {plaintext}"}) + assert r.status_code == 200 + assert r.json() == {"jaccount": user.jaccount} + + +def test_missing_header_is_401(client: TestClient) -> None: + r = client.get("/me") + assert r.status_code == 401 + + +def test_malformed_header_is_401(client: TestClient) -> None: + r = client.get("/me", headers={"Authorization": "Basic abc"}) + assert r.status_code == 401 + + +def test_unknown_token_is_401(client: TestClient) -> None: + r = client.get("/me", headers={"Authorization": "Bearer st_NEVER_EXISTED"}) + assert r.status_code == 401 + + +def test_revoked_token_is_401(client: TestClient, session: Session) -> None: + plaintext = "st_REVOKED_007" + _seed_user_with_token(session, plaintext) + # mark all tokens revoked + from datetime import datetime + + for row in session.exec( # noqa: SLF001 — test + __import__("sqlmodel").select(StudentToken) + ).all(): + row.revoked_at = datetime.now(UTC) + session.add(row) + session.commit() + + r = client.get("/me", headers={"Authorization": f"Bearer {plaintext}"}) + assert r.status_code == 401 + + +def test_inactive_user_is_401(client: TestClient, session: Session) -> None: + plaintext = "st_INACTIVE_USER_002" + user = _seed_user_with_token(session, plaintext) + user.is_active = False + session.add(user) + session.commit() + r = client.get("/me", headers={"Authorization": f"Bearer {plaintext}"}) + assert r.status_code == 401 + + +def test_one_active_token_per_user_lookup_picks_it(client: TestClient, session: Session) -> None: + """Multiple historical tokens — only the active one authorizes.""" + user = User(display_name="A", email="a@x", jaccount="multitok") + session.add(user) + session.commit() + from datetime import datetime + + # old token, revoked + session.add( + StudentToken( + user_id=user.id, + token_hash=_hash("st_OLD_REVOKED"), + prefix="st_OLD_R", + revoked_at=datetime.now(UTC), + ) + ) + # current active token + session.add( + StudentToken( + user_id=user.id, + token_hash=_hash("st_NEW_ACTIVE"), + prefix="st_NEW_A", + ) + ) + session.commit() + + r_old = client.get("/me", headers={"Authorization": "Bearer st_OLD_REVOKED"}) + assert r_old.status_code == 401 + + r_new = client.get("/me", headers={"Authorization": "Bearer st_NEW_ACTIVE"}) + assert r_new.status_code == 200 diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py new file mode 100644 index 0000000..c687ea6 --- /dev/null +++ b/backend/tests/test_ingest.py @@ -0,0 +1,348 @@ +"""TDD: ingest endpoints.""" + +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import Engine +from sqlmodel import Session + +from aimdware_backend.db import get_session +from aimdware_backend.main import create_app +from aimdware_backend.models import Course, Enrollment, Role, StudentToken, User + + +def _hash(plaintext: str) -> bytes: + return hashlib.sha256(plaintext.encode()).digest() + + +@pytest.fixture +def client(engine: Engine, session: Session) -> TestClient: + app = create_app() + + def override_session(): + with Session(engine) as s: + yield s + + app.dependency_overrides[get_session] = override_session + return TestClient(app) + + +@pytest.fixture +def enrolled_student(session: Session) -> tuple[str, User, Course]: + """Seed a user enrolled in ECE4721J + an active token. Returns (plaintext, user, course).""" + user = User(display_name="Zhang San", email="z@x", jaccount="zhangsan") + course = Course(code="ECE4721J", title="Intro to Systems", semester="2026-spring") + session.add_all([user, course]) + session.commit() + session.add(Enrollment(user_id=user.id, course_id=course.id, role=Role.student)) + plaintext = "st_ENROLLED_STUDENT_TOKEN" + session.add(StudentToken(user_id=user.id, token_hash=_hash(plaintext), prefix=plaintext[:8])) + session.commit() + return plaintext, user, course + + +def _body(course_code: str = "ECE4721J", **overrides) -> dict: + sess = str(uuid4()) + base = { + "record_id": str(uuid4()), + "session_id": sess, + "turn_count": 1, + "course_code": course_code, + "assignment": "hw1", + "blob_hash": "ab" * 32, + "blob_uri": f"aimdware/{course_code}/hw1/{sess}.json", + "blob_size": 1234, + "model": "gpt-4o-mini", + "prompt_tokens": 10, + "completion_tokens": 20, + "ts": datetime.now(UTC).isoformat(), + "router_version": "0.0.0", + "client_meta": {"agent": "cline"}, + } + base.update(overrides) + return base + + +# --------- /ingest/health --------- + + +def test_health_is_unauthenticated(client: TestClient) -> None: + r = client.get("/ingest/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +# --------- /ingest/context happy paths --------- + + +def test_post_context_creates_with_202( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + body = _body() + r = client.post( + "/ingest/context", + json=body, + headers={"Authorization": f"Bearer {plaintext}"}, + ) + assert r.status_code == 202 + assert r.json()["id"] == body["record_id"] + assert r.json()["status"] == "created" + + +def test_post_context_idempotent_replay_returns_200( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + body = _body() + h = {"Authorization": f"Bearer {plaintext}"} + r1 = client.post("/ingest/context", json=body, headers=h) + assert r1.status_code == 202 + r2 = client.post("/ingest/context", json=body, headers=h) + assert r2.status_code == 200 + assert r2.json()["status"] == "exists" + + +def test_post_context_mismatched_body_returns_409( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + body = _body() + h = {"Authorization": f"Bearer {plaintext}"} + assert client.post("/ingest/context", json=body, headers=h).status_code == 202 + + body2 = dict(body) + body2["blob_hash"] = "cd" * 32 # different content for same record_id + r = client.post("/ingest/context", json=body2, headers=h) + assert r.status_code == 409 + + +# --------- /ingest/context auth + scope --------- + + +def test_post_context_missing_auth_is_401(client: TestClient) -> None: + r = client.post("/ingest/context", json=_body()) + assert r.status_code == 401 + + +def test_post_context_unknown_course_is_404( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + r = client.post( + "/ingest/context", + json=_body(course_code="NOSUCHCOURSE"), + headers={"Authorization": f"Bearer {plaintext}"}, + ) + assert r.status_code == 404 + + +def test_post_context_not_enrolled_is_403( + client: TestClient, enrolled_student: tuple[str, User, Course], session: Session +) -> None: + plaintext, _, _ = enrolled_student + other = Course(code="OTHER1", title="t", semester="s") + session.add(other) + session.commit() + + r = client.post( + "/ingest/context", + json=_body(course_code="OTHER1"), + headers={"Authorization": f"Bearer {plaintext}"}, + ) + assert r.status_code == 403 + + +# --------- /ingest/context/{id}/uploaded --------- + + +def test_mark_uploaded_transitions_status( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + body = _body() + h = {"Authorization": f"Bearer {plaintext}"} + client.post("/ingest/context", json=body, headers=h) + r = client.post(f"/ingest/context/{body['record_id']}/uploaded", headers=h) + assert r.status_code == 200 + assert r.json()["status"] == "uploaded" + + +def test_mark_uploaded_is_idempotent( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + body = _body() + h = {"Authorization": f"Bearer {plaintext}"} + client.post("/ingest/context", json=body, headers=h) + client.post(f"/ingest/context/{body['record_id']}/uploaded", headers=h) + r = client.post(f"/ingest/context/{body['record_id']}/uploaded", headers=h) + assert r.status_code == 200 + assert r.json()["status"] == "uploaded" + + +def test_mark_uploaded_other_users_record_is_404( + client: TestClient, + enrolled_student: tuple[str, User, Course], + session: Session, +) -> None: + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + body = _body() + client.post("/ingest/context", json=body, headers=h) + + # Different student token + other = User(display_name="Other", email="o@x", jaccount="other") + session.add(other) + session.commit() + other_plain = "st_OTHER_USER_TOKEN_xx" + session.add( + StudentToken( + user_id=other.id, + token_hash=_hash(other_plain), + prefix=other_plain[:8], + ) + ) + session.commit() + + r = client.post( + f"/ingest/context/{body['record_id']}/uploaded", + headers={"Authorization": f"Bearer {other_plain}"}, + ) + assert r.status_code == 404 + + +def test_mark_uploaded_unknown_id_is_404( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + plaintext, _, _ = enrolled_student + r = client.post( + f"/ingest/context/{uuid4()}/uploaded", + headers={"Authorization": f"Bearer {plaintext}"}, + ) + assert r.status_code == 404 + + +def test_post_context_duplicate_session_turn_returns_409( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + """A second record on the same (session_id, turn_count) — with a + different record_id — must be rejected with 409, not a 500. + + This guards the new UNIQUE(session_id, turn_count) DB constraint. + """ + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + sess = str(uuid4()) + + body1 = _body() + body1["session_id"] = sess + body1["blob_uri"] = f"aimdware/ECE4721J/hw1/{sess}.json" + body1["turn_count"] = 1 + r1 = client.post("/ingest/context", json=body1, headers=h) + assert r1.status_code == 202, r1.text + + body2 = _body() # new record_id, same session/turn + body2["session_id"] = sess + body2["blob_uri"] = f"aimdware/ECE4721J/hw1/{sess}.json" + body2["turn_count"] = 1 + r2 = client.post("/ingest/context", json=body2, headers=h) + assert r2.status_code == 409, r2.text + + +# --------- new validation regression tests --------- + + +def test_blob_hash_must_be_64_hex( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + """Wrong-length hex used to be silently accepted (then audit + forever-fails). Non-hex used to crash to 500 (then router + retried forever). Both must now be 422.""" + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + + short = _body() + short["blob_hash"] = "ab" * 30 # 60 hex chars + assert client.post("/ingest/context", json=short, headers=h).status_code == 422 + + nonhex = _body() + nonhex["blob_hash"] = "X" * 64 + assert client.post("/ingest/context", json=nonhex, headers=h).status_code == 422 + + +def test_blob_uri_must_match_canonical_shape( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + """Path traversal / absolute URLs / weird chars used to slip + through ingest, then admin payload-fetch happily resolved them.""" + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + + bad_paths = [ + "aimdware/../private/x.json", # traversal + "https://attacker.example/payload.json", # absolute URL + "aimdware/ECE4721J/hw1/not-a-uuid.json", # bad filename + "aimdware/ECE4721J/hw1/00000000-0000-0000-0000-000000000000.txt", # wrong ext + "../../etc/passwd", # blatant traversal + ] + for bad in bad_paths: + body = _body() + body["blob_uri"] = bad + r = client.post("/ingest/context", json=body, headers=h) + assert r.status_code == 422, f"blob_uri={bad!r} should be rejected, got {r.status_code}" + + +def test_blob_uri_must_match_body_course_assignment_and_session( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + """A well-shaped path is still invalid if it points at a different + course / assignment / session than the trusted request fields.""" + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + body = _body() + other_session = uuid4() + + bad_paths = [ + f"aimdware/OTHER/hw1/{body['session_id']}.json", + f"aimdware/ECE4721J/hw2/{body['session_id']}.json", + f"aimdware/ECE4721J/hw1/{other_session}.json", + ] + for bad in bad_paths: + candidate = dict(body) + candidate["blob_uri"] = bad + r = client.post("/ingest/context", json=candidate, headers=h) + assert r.status_code == 422, f"blob_uri={bad!r} should be bound to body fields" + + +def test_idempotent_compare_rejects_mismatched_metadata( + client: TestClient, enrolled_student: tuple[str, User, Course] +) -> None: + """Replay with same record_id but different audit-relevant metadata + must 409, naming the mismatched fields.""" + plaintext, _, _ = enrolled_student + h = {"Authorization": f"Bearer {plaintext}"} + body = _body() + assert client.post("/ingest/context", json=body, headers=h).status_code == 202 + + # Try replaying with the SAME record_id but a different session_id — + # this was previously silently accepted (200). + replay = dict(body) + replay_session = str(uuid4()) + replay["session_id"] = replay_session + replay["blob_uri"] = f"aimdware/ECE4721J/hw1/{replay_session}.json" + r = client.post("/ingest/context", json=replay, headers=h) + assert r.status_code == 409 + assert "session_id" in r.json()["detail"] + + # And same with turn_count. + replay2 = dict(body) + replay2["turn_count"] = 7 + r = client.post("/ingest/context", json=replay2, headers=h) + assert r.status_code == 409 + assert "turn_count" in r.json()["detail"] diff --git a/backend/tests/test_jbox_real.py b/backend/tests/test_jbox_real.py new file mode 100644 index 0000000..2edf336 --- /dev/null +++ b/backend/tests/test_jbox_real.py @@ -0,0 +1,76 @@ +"""Real Tbox integration test for TboxWebDAVReader. + +Skipped automatically when no Tbox is reachable. Override via env vars: + AIMDWARE_TBOX_URL (default http://127.0.0.1:50471) + AIMDWARE_TBOX_USER (default admin) + AIMDWARE_TBOX_PASS (default admin) +""" + +from __future__ import annotations + +import contextlib +import os +import time +from collections.abc import Iterator + +import httpx +import pytest + +from aimdware_backend.jbox import JboxNotFound, TboxWebDAVReader + +TBOX_URL = os.environ.get("AIMDWARE_TBOX_URL", "http://127.0.0.1:50471") +TBOX_USER = os.environ.get("AIMDWARE_TBOX_USER", "admin") +TBOX_PASS = os.environ.get("AIMDWARE_TBOX_PASS", "admin") + + +def _reachable() -> bool: + try: + httpx.get(TBOX_URL, timeout=1.5) + return True + except httpx.HTTPError: + return False + + +pytestmark = pytest.mark.skipif(not _reachable(), reason=f"Tbox not reachable at {TBOX_URL}") + + +@pytest.fixture() +def seeded_blob() -> Iterator[tuple[str, bytes]]: + """Seed Tbox with a known blob via direct WebDAV; tear it down after.""" + subdir = f"aimdware-it-py-{int(time.time() * 1000)}" + path = f"{subdir}/sample.json" + payload = f'{{"hello":"backend","ts":{int(time.time() * 1000)}}}'.encode() + auth = (TBOX_USER, TBOX_PASS) + + # MKCOL parent + PUT + httpx.request("MKCOL", f"{TBOX_URL}/{subdir}/", auth=auth).raise_for_status() + httpx.put(f"{TBOX_URL}/{path}", auth=auth, content=payload).raise_for_status() + + yield path, payload + + # Cleanup (best effort). + with contextlib.suppress(httpx.HTTPError): + httpx.delete(f"{TBOX_URL}/{subdir}", auth=auth) + + +@pytest.mark.asyncio +async def test_reader_fetches_seeded_blob(seeded_blob: tuple[str, bytes]) -> None: + path, payload = seeded_blob + reader = TboxWebDAVReader(TBOX_URL, auth=(TBOX_USER, TBOX_PASS)) + got = await reader.get(path) + assert got == payload + + +@pytest.mark.asyncio +async def test_reader_raises_jbox_not_found_on_missing_blob() -> None: + reader = TboxWebDAVReader(TBOX_URL, auth=(TBOX_USER, TBOX_PASS)) + with pytest.raises(JboxNotFound): + await reader.get(f"aimdware-it-py-missing-{int(time.time() * 1000)}/nope.json") + + +@pytest.mark.asyncio +async def test_reader_raises_on_bad_credentials(seeded_blob: tuple[str, bytes]) -> None: + path = seeded_blob[0] + reader = TboxWebDAVReader(TBOX_URL, auth=("admin", "definitely-wrong")) + with pytest.raises(httpx.HTTPStatusError): + await reader.get(path) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..2eaecba --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,205 @@ +"""TDD: SQLModel schemas.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from aimdware_backend.models import ( + BlobStatus, + ContextRecord, + Course, + Enrollment, + Role, + StudentToken, + User, +) + + +def test_user_round_trip(session: Session) -> None: + u = User(display_name="Zhang San", email="z@sjtu.edu.cn", jaccount="zhangsan") + session.add(u) + session.commit() + session.refresh(u) + + assert u.id is not None + assert u.is_active is True + assert u.created_at is not None + + fetched = session.exec(select(User).where(User.jaccount == "zhangsan")).one() + assert fetched.email == "z@sjtu.edu.cn" + + +def test_user_email_unique(session: Session) -> None: + session.add(User(display_name="A", email="dup@x", jaccount="a")) + session.commit() + session.add(User(display_name="B", email="dup@x", jaccount="b")) + with pytest.raises(IntegrityError): + session.commit() + + +def test_user_jaccount_unique(session: Session) -> None: + session.add(User(display_name="A", email="a@x", jaccount="dup")) + session.commit() + session.add(User(display_name="B", email="b@x", jaccount="dup")) + with pytest.raises(IntegrityError): + session.commit() + + +def test_course_code_unique(session: Session) -> None: + session.add(Course(code="ECE4721J", title="Intro to Systems", semester="2026-spring")) + session.commit() + session.add(Course(code="ECE4721J", title="dup", semester="2026-fall")) + with pytest.raises(IntegrityError): + session.commit() + + +def test_enrollment_composite_pk(session: Session) -> None: + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="C1", title="t", semester="s") + session.add_all([user, course]) + session.commit() + + e1 = Enrollment(user_id=user.id, course_id=course.id, role=Role.student) + session.add(e1) + session.commit() + + # Same (user, course) pair → composite PK violation + e2 = Enrollment(user_id=user.id, course_id=course.id, role=Role.admin) + session.add(e2) + with pytest.raises(IntegrityError): + session.commit() + + +def test_student_token_persists_hash_bytes(session: Session) -> None: + user = User(display_name="A", email="a@x", jaccount="a") + session.add(user) + session.commit() + + digest = b"\xde\xad\xbe\xef" * 8 + tok = StudentToken(user_id=user.id, token_hash=digest, prefix="st_test01") + session.add(tok) + session.commit() + session.refresh(tok) + + assert tok.id is not None + assert tok.revoked_at is None + assert tok.token_hash == digest + + +def test_context_record_persists_with_blob_metadata(session: Session) -> None: + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="C1", title="t", semester="s") + session.add_all([user, course]) + session.commit() + + rec = ContextRecord( + user_id=user.id, + course_id=course.id, + assignment="hw1", + session_id=uuid4(), + turn_count=1, + model="gpt-4o-mini", + prompt_tokens=10, + completion_tokens=20, + router_version="0.0.0", + client_meta={"agent": "cline"}, + blob_uri="aimdware/C1/hw1/abc.json", + blob_hash=b"\x00" * 32, + blob_size=12345, + ) + session.add(rec) + session.commit() + session.refresh(rec) + + assert rec.id is not None + assert rec.ts is not None + assert rec.blob_status == BlobStatus.pending + assert rec.client_meta == {"agent": "cline"} + + +def test_context_record_id_is_pk_unique(session: Session) -> None: + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="C1", title="t", semester="s") + session.add_all([user, course]) + session.commit() + + rec_id = uuid4() + base = dict( + user_id=user.id, + course_id=course.id, + assignment="hw1", + session_id=uuid4(), + turn_count=1, + model="gpt", + router_version="0.0.0", + blob_uri="x.json", + blob_hash=b"\x00" * 32, + blob_size=1, + ) + session.add(ContextRecord(id=rec_id, **base)) + session.commit() + session.add(ContextRecord(id=rec_id, **base)) + with pytest.raises(IntegrityError): + session.commit() + + +def test_context_record_unique_session_turn(session: Session) -> None: + """Two records with the same (session_id, turn_count) must be rejected.""" + user = User(display_name="A", email="a@x", jaccount="a") + course = Course(code="C2", title="t", semester="s") + session.add_all([user, course]) + session.commit() + + sess_id = uuid4() + base = dict( + user_id=user.id, + course_id=course.id, + assignment="hw1", + session_id=sess_id, + turn_count=1, + model="gpt", + router_version="0.0.0", + blob_uri="x.json", + blob_hash=b"\x00" * 32, + blob_size=1, + ) + session.add(ContextRecord(**base)) + session.commit() + session.add(ContextRecord(**base)) # same session_id + turn_count + with pytest.raises(IntegrityError): + session.commit() + + +def test_student_token_active_per_user_partial_unique(session: Session) -> None: + """Two active tokens (revoked_at IS NULL) for the same user must be + rejected. This is the partial unique index that lives only in + Alembic 0001 — conftest mirrors it so this test is meaningful.""" + import hashlib + + user = User(display_name="A", email="a@x", jaccount="a") + session.add(user) + session.commit() + session.refresh(user) + + session.add( + StudentToken( + user_id=user.id, + token_hash=hashlib.sha256(b"t1").digest(), + prefix="t1aaaaaa", + ) + ) + session.commit() + + session.add( + StudentToken( + user_id=user.id, + token_hash=hashlib.sha256(b"t2").digest(), + prefix="t2aaaaaa", + ) + ) + with pytest.raises(IntegrityError): + session.commit() diff --git a/backend/tests/test_roster.py b/backend/tests/test_roster.py new file mode 100644 index 0000000..a53b7fc --- /dev/null +++ b/backend/tests/test_roster.py @@ -0,0 +1,48 @@ +"""Tests for roster CSV parsing (name, student_id, jaccount).""" + +from __future__ import annotations + +import pytest + +from aimdware_backend.roster import RosterRow, read_roster + + +def test_parses_name_studentid_jaccount(tmp_path) -> None: + p = tmp_path / "roster.csv" + p.write_text( + "名字,学号,jaccount\n张三,5190100001,zhangsan\n李四,5190100002,lisi\n", + encoding="utf-8", + ) + assert read_roster(str(p)) == [ + RosterRow(name="张三", student_id="5190100001", jaccount="zhangsan"), + RosterRow(name="李四", student_id="5190100002", jaccount="lisi"), + ] + + +def test_skips_header_row_and_blank_lines(tmp_path) -> None: + p = tmp_path / "r.csv" + p.write_text("name,student_id,jaccount\n\nAlice,5190,alice\n\n", encoding="utf-8") + rows = read_roster(str(p)) + assert [r.jaccount for r in rows] == ["alice"] + + +def test_empty_student_id_becomes_none(tmp_path) -> None: + p = tmp_path / "r.csv" + p.write_text("Bob,,bob\n", encoding="utf-8") + rows = read_roster(str(p)) + assert rows[0].student_id is None + assert rows[0].jaccount == "bob" + + +def test_missing_jaccount_raises(tmp_path) -> None: + p = tmp_path / "r.csv" + p.write_text("Carol,5191,\n", encoding="utf-8") + with pytest.raises(ValueError): + read_roster(str(p)) + + +def test_tolerates_utf8_bom(tmp_path) -> None: + p = tmp_path / "r.csv" + p.write_text("Dave,5192,dave\n", encoding="utf-8") + rows = read_roster(str(p)) + assert rows[0].jaccount == "dave" diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py new file mode 100644 index 0000000..2c9ce48 --- /dev/null +++ b/backend/tests/test_settings.py @@ -0,0 +1,35 @@ +"""Settings load from a .env file, with real env vars taking precedence.""" + +from __future__ import annotations + +import pytest + +_VARS = ("AIMDWARE_ADMIN_SECRET", "AIMDWARE_DATABASE_URL", "AIMDWARE_TBOX_USER") + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for var in _VARS: + monkeypatch.delenv(var, raising=False) + + +def test_settings_load_from_dotenv(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("AIMDWARE_ADMIN_SECRET=from-dotenv\nAIMDWARE_TBOX_USER=alice\n") + monkeypatch.chdir(tmp_path) + + from aimdware_backend.settings import Settings + + s = Settings() + assert s.admin_secret == "from-dotenv" + assert s.tbox_user == "alice" + + +def test_real_env_var_overrides_dotenv(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("AIMDWARE_ADMIN_SECRET=from-dotenv\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("AIMDWARE_ADMIN_SECRET", "from-real-env") + + from aimdware_backend.settings import Settings + + s = Settings() + assert s.admin_secret == "from-real-env" diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..3b1571f --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,864 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aimdware-backend" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, + { name = "sqlmodel" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "prek" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-settings", specifier = ">=2.5" }, + { name = "sqlmodel", specifier = ">=0.0.22" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.6" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "prek", specifier = ">=0.2" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.14.5" }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prek" +version = "0.3.13" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/59/0a279983f96bd5d538b4975f0a23121082aa3b8560b6649fdf61f8011b07/prek-0.3.13.tar.gz", hash = "sha256:c48586ee3708bfbf3df80121f55583e9a7d0fa166b08172c091fe5971e92a0ac", size = 444848, upload-time = "2026-05-05T18:07:09.076Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/6a/9baa2bda21dccc2927e952416f6cc23a75eb99c9ed18837164ac2e4a5640/prek-0.3.13-py3-none-linux_armv6l.whl", hash = "sha256:b00d38f01235073c35aa5f48df57fefef45a6cec2ae0884d750345a2c7220370", size = 5506622, upload-time = "2026-05-05T18:06:53.091Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/56/77/d44b5d9bdca0879b865f8e47bf84cf5dc9e8b358d029e6d9b83d8809c116/prek-0.3.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0d89ac712c60e34d1550a606ad5fdfb8ad71d44ced8afa2fa5cbc106be4abd9e", size = 5878743, upload-time = "2026-05-05T18:07:23.164Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/08/cf/19e8525cde8b3aa12858aca434d1fa653ef3b152da5af11eafc857634dc2/prek-0.3.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f9b5265863d18b5be4ea094fdce4fd6ca61a8c89a70ee3d8ee153b3e0ed6b272", size = 5434909, upload-time = "2026-05-05T18:07:25.276Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7a/9a/e5f97194782de4dab622ce09dafb3ebdd2ee4d354a83ac4def7ebeee236c/prek-0.3.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:64b59a1550780af2bba37297c704b17f81d8e9df6288af1fab4017938e33b1db", size = 5697536, upload-time = "2026-05-05T18:07:05.475Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/8c/e1f548ffc4b227e4c2b5a9b30f5978a7e0e6dad51305b97a2ba5b2a923e7/prek-0.3.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ce6cd8f114ba9bbdbe97422103fd886101949b1c42e588a7543c4436ead2020", size = 5428160, upload-time = "2026-05-05T18:07:01.489Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8a/44/abd919b00905a32d21dca2cec32c707860cf217da2431b62dd52684b310e/prek-0.3.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc03e924a24d8d961f56195853c8b206cb196be6db4ad8312125dae847d718ac", size = 5827275, upload-time = "2026-05-05T18:07:17.437Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/af/ed/cafd2b80d58a83faf8371c6543bd1475a2224242a3294da7f8582f6aa551/prek-0.3.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ca8c526a23873177fb3b92013500b08ef5f8bedc7263f9f3a44dd2f49645a26", size = 6710293, upload-time = "2026-05-05T18:07:10.663Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/35/09/52a4a27596b764173a34d74db09356b30faaacb4a1075b75adbc036a0008/prek-0.3.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bbb175478438a871e3281d2c3c3f067288af73ad81707a9bdebfd769766c7d", size = 6096556, upload-time = "2026-05-05T18:07:19.46Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/63/60/80f61729ce6498815d46d5580cf76da2c157c9b6494046183682441a0ea3/prek-0.3.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a65327a014d838341af757dfc05a706d10e8e33f039bc32bb3dbe2fa21c440c0", size = 5693267, upload-time = "2026-05-05T18:07:03.66Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/36/9d/c7a663fe70676ffab2e0c6c9a71997a3ccd002ed5bc60b7422a937911af0/prek-0.3.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b6a200843a36a5b0c41764ce7639ccb3471d48b097f1c5e3fc8f034219b42626", size = 5532865, upload-time = "2026-05-05T18:07:15.237Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/68/506ef5a235536030e16f61e7210474554f6e05f845f27df5877d2dbb1a06/prek-0.3.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:bdacaad8f35f343e063d251211fe34db1de9e5cc591795361ad69a6485202258", size = 5395951, upload-time = "2026-05-05T18:06:55.183Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/00/22d7c6db7f43b58f7d015913c12660c9bbc82751cff6cfd8c31993cf30eb/prek-0.3.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f00328f1c520d8fefb910ab0d3c6764ee330d227952baa19b7e3de7242bd8b3b", size = 5681195, upload-time = "2026-05-05T18:07:12.804Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/10/e3/fdf9882238796914ddaf11381a9083b374980156200a953324f6c795f34d/prek-0.3.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e5530a867bcf5b172b7513a64e71b06a337d1d184696227ae953845867376b8d", size = 6212085, upload-time = "2026-05-05T18:07:07.213Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ca/1d/528759931344b5c7103085798f5fa2e86d27d9410b753a6bcbe7726aa8ba/prek-0.3.13-py3-none-win32.whl", hash = "sha256:326fac2bdce00074ce6c5046b861d310638aee2b9de1ed241ba7eb32bdc83898", size = 5199566, upload-time = "2026-05-05T18:07:21.416Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6b/d0/8715ee837c73314a02767d20652cc312d1b6ff6733fa00f52de2b648bc3a/prek-0.3.13-py3-none-win_amd64.whl", hash = "sha256:841049f89f5ec9f4035299283d11e566ac5a068e3742ead1055ea04f886831fc", size = 5589599, upload-time = "2026-05-05T18:06:57.28Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ff/cf/0af0b15be0ebd82f7e50adee149b05a73533d78cb1b97cb889f0647ebffe/prek-0.3.13-py3-none-win_arm64.whl", hash = "sha256:a9fd74e0aec550c6b8d41076fdcdd6ff121cd7d94d743c1338bd794784e3c775", size = 5419029, upload-time = "2026-05-05T18:06:59.645Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.38" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" } +sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://mirrors.ustc.edu.cn/pypi/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://mirrors.ustc.edu.cn/pypi/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/llm-client/.gitignore b/llm-client/.gitignore new file mode 100644 index 0000000..cdec036 --- /dev/null +++ b/llm-client/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +aimdware.yaml diff --git a/llm-client/.oxfmtrc.json b/llm-client/.oxfmtrc.json new file mode 100644 index 0000000..65952cb --- /dev/null +++ b/llm-client/.oxfmtrc.json @@ -0,0 +1,24 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "objectWrap": "preserve", + "bracketSameLine": false, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "checkIgnorePragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "vueIndentScriptAndStyle": false, + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": false, + "plugins": [] +} diff --git a/llm-client/.oxlintrc.json b/llm-client/.oxlintrc.json new file mode 100644 index 0000000..02a405b --- /dev/null +++ b/llm-client/.oxlintrc.json @@ -0,0 +1,15 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn" + }, + "rules": { + "no-await-in-loop": "off", + "no-unmodified-loop-condition": "off", + "unicorn/no-array-sort": "off", + "unicorn/consistent-function-scoping": "off" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml new file mode 100644 index 0000000..bfdda95 --- /dev/null +++ b/llm-client/aimdware.example.yaml @@ -0,0 +1,68 @@ +# aimdware-router config +# +# Quickstart: +# 1. cp aimdware.example.yaml aimdware.yaml +# 2. fill in the REPLACE_ME values +# 3. ./aimdware-router --config ./aimdware.yaml +# +# Point your tooling (codex / aider / cursor / openai-python / curl ...) +# at http://127.0.0.1:/v1/chat/completions with any api_key. +# +# ---- required ---- + +# Bearer token your TT issued for this course. Treat as a password. +student_token: REPLACE_ME + +# Course code, must match what the TT enrolled you in. +course: REPLACE_ME + +# Assignment label inside that course. Must be the TT-decreed slug +# for this hw / lab / exam: A-Z, a-z, 0-9, underscore, dot, or hyphen. +# Every captured conversation will land under this assignment's folder +# on jbox, so picking the right one is what lets the TT find your work. +assignment: REPLACE_ME + +# Backend URL (TT will tell you). +backend_url: https://aimdware.example.edu + +# Tbox WebDAV gateway — usually a local instance you run yourself, +# logged into your jbox via jaccount. The router will create a +# course-scoped folder under here and PUT every captured payload. +tbox_url: http://127.0.0.1:50471 +tbox_user: REPLACE_ME +tbox_pass: REPLACE_ME + +# Upstream LLM provider. `openai` means any OpenAI-compatible API +# (OpenAI, OpenRouter, Kimi, GLM, DeepSeek, Qwen, chatanywhere, ...). +# The router exposes both /v1/chat/completions and /v1/responses and +# forwards them to this base_url. `codex` is a native Responses provider; +# point compatible clients at /v1/responses. `copilot` uses local +# subscription auth. For subscriptions, run: +# ./aimdware-router --config ./aimdware.yaml auth login codex +# or: +# ./aimdware-router --config ./aimdware.yaml auth login copilot +upstream: + plugin: openai + base_url: https://api.openai.com + api_key: sk-REPLACE_ME + +# ChatGPT/Codex subscription example: +# upstream: +# plugin: codex +# +# GitHub Copilot subscription example: +# upstream: +# plugin: copilot + +# ---- optional (defaults shown) ---- + +# Local port the router listens on. Whatever tool you point at the +# router uses this. +# port: 12345 + +# Where the router stages outbox state + cached blobs. Anything inside +# is safe to delete; it will be re-fetched from upstream/Tbox as needed. +# local_cache_dir: ~/.cache/aimdware + +# WebDAV path on Tbox. Defaults to "aimdware/". +# jbox_remote_path: aimdware/ECE4721J diff --git a/llm-client/bun.lock b/llm-client/bun.lock new file mode 100644 index 0000000..e5e6f2c --- /dev/null +++ b/llm-client/bun.lock @@ -0,0 +1,181 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "aimdware-llm-client", + "dependencies": { + "webdav": "^5.10.0", + "yaml": "^2.5.1", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "^1.1.10", + "oxfmt": "^0.49.0", + "oxlint": "^1.41.0", + "typescript": "^5.6.3", + }, + }, + }, + "packages": { + "@buttercup/fetch": ["@buttercup/fetch@0.2.1", "", { "optionalDependencies": { "node-fetch": "^3.3.0" } }, "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg=="], + + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8x5DN9CsFfb432sHa9NyqX5XisGUdA53LPEGSdv/VniS+v4uEOR8Orv7A9QSB98Xxgp0t6r31DzQA/wpIobGqQ=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e0+DSVzk4ewhMVKNYDaRTmP81jNMBWR1X9al0cVKWS+hDM/dElNqD5zjTOCuLOZc4oOdp2Gx2ldrVL+yYo9TZQ=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-W+mjtYtrQvFbXT/uNT+221OBhGRZ8UqNsLxjTWsjZ4GsQnRdvRC/N2NCK86BcamWr7lsTxwpwN3PULnr78sgcQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Rtv6UevV7czDlLqil+NZUe4d8gs8jQo/zScSpumwyf7I+fSdLc+hc8AF3MQC7ymxSMMD9+vfiqQlsIf7wOAzXA=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sBi+8C/Q/MdKa5FL8ibAUCdhFBGFH7HFN/Qoyd5xQbZ/0ky3NMPpKfIBpaH0lhK2dXkGLczVQUoZ+xuNSerCdQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JIfWenFhlzx+O8YygyZhoHFzTsdgDhxhbDRnE2iJLnnM5pWKScFvPECO2vOlA7JqJ/9S1g3uzEKuRCkHFwTjvA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iNzkMPG18jPkwBOZ4/HEjwqfzAjq4RrUQ0CgId/fC1ENvYD5jLVAaU/gWgpiqP1ys07kxSsSggDd1fp3E7mQHw=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BPHA/NN3LvoIXiid+iz3BHt5V0Rzx0tXAqRUovwE1NsbDaLG9e8mtv7evDGRIkVQacqTDBv0XL25THHsxSJosQ=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-3Eroshe+s69htC9JIL0+zLGQczLtRKezkMhwqQC21VC5Z/fuLvzLfbAOLgJLUq601H8gDYjy7deYycfOBjCvWg=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-fnaERGgsxGm0lKAmO72EYR4BA3qBnzBTJBTi6EtUMq1D4R7EexRBMU4voXnx4TXla3SEDl9x4uNp/18SbkPjGg=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-rBwasMl1Uul1MCCeTGEFKnOTL7VUxHf+634jWStrQAbzpBJgd5Yz5m4F7exVCsoI8PHn57dNjssXagXLCLB5yA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BoC/F9xHe2y/deuBGA5Aw7bes07OD2gcL2wlpzTrfImR92vPP7S/k3LBTyspQZCNIVNdagkELcqKELwMLGIfAg=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-umY6jFADAo/oztFKl8D/S6vSrG6oBpEskcentiRuz42kZVU2kfDXMWCYavxyZR2bwPjqkHpcHZ6EZFiH3Qj9ZA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.49.0", "", { "os": "none", "cpu": "arm64" }, "sha512-J85zQMiw2pXiGPK+OusmDvSnJ/dgpgN7VgmB2zOBtgS8F+nsOUfSg9ZEBrwbQscjZ7tkPbm38CG4VF5f53MsiA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-38K67XR++CoFFORDd4sMFwUVAnD6msYBdGTei+qvKGrRPO6S2PbrYPNL/eQQ1RgnnxOegNba0YQwg6uRkNcw6A=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rXVe0HICwQF0dBgbQtBCoYf8x/SidPIdhyQl+iPuJlV7suV+qDv7yUEB3wQ4qC3nOeNxz287SwFXKzyr0kWgEg=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-gwWLwSEmBBfIK/Wh7GGd658161o4RKAvHWRaRQbJm571iQXGKfyr7UKsI1vsWvDlNLc30CxJDc8mMmCvJ/kczQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "byte-length": ["byte-length@1.0.2", "", {}, "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="], + + "charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="], + + "crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "hot-patcher": ["hot-patcher@2.0.1", "", {}, "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="], + + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + + "layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="], + + "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "nested-property": ["nested-property@4.0.0", "", {}, "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "oxfmt": ["oxfmt@0.49.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.49.0", "@oxfmt/binding-android-arm64": "0.49.0", "@oxfmt/binding-darwin-arm64": "0.49.0", "@oxfmt/binding-darwin-x64": "0.49.0", "@oxfmt/binding-freebsd-x64": "0.49.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.49.0", "@oxfmt/binding-linux-arm-musleabihf": "0.49.0", "@oxfmt/binding-linux-arm64-gnu": "0.49.0", "@oxfmt/binding-linux-arm64-musl": "0.49.0", "@oxfmt/binding-linux-ppc64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-musl": "0.49.0", "@oxfmt/binding-linux-s390x-gnu": "0.49.0", "@oxfmt/binding-linux-x64-gnu": "0.49.0", "@oxfmt/binding-linux-x64-musl": "0.49.0", "@oxfmt/binding-openharmony-arm64": "0.49.0", "@oxfmt/binding-win32-arm64-msvc": "0.49.0", "@oxfmt/binding-win32-ia32-msvc": "0.49.0", "@oxfmt/binding-win32-x64-msvc": "0.49.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "optionalPeers": ["svelte"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ=="], + + "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "path-posix": ["path-posix@1.0.0", "", {}, "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="], + + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], + + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "webdav": ["webdav@5.10.0", "", { "dependencies": { "@buttercup/fetch": "^0.2.1", "base-64": "^1.0.0", "byte-length": "^1.0.2", "entities": "^6.0.1", "fast-xml-parser": "^5.7.2", "hot-patcher": "^2.0.1", "layerr": "^3.0.0", "md5": "^2.3.0", "minimatch": "^9.0.9", "nested-property": "^4.0.0", "node-fetch": "^3.3.2", "path-posix": "^1.0.0", "url-join": "^5.0.0", "url-parse": "^1.5.10" } }, "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A=="], + + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/llm-client/package.json b/llm-client/package.json new file mode 100644 index 0000000..31033a9 --- /dev/null +++ b/llm-client/package.json @@ -0,0 +1,33 @@ +{ + "name": "aimdware-llm-client", + "version": "0.1.0", + "private": true, + "type": "module", + "module": "src/main.ts", + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "format": "bun --bun x oxfmt src/", + "format:check": "bun --bun x oxfmt --check src/", + "lint": "bun --bun x oxlint src/", + "dev": "bun run src/main.ts", + "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/aimdware-router", + "build:macos-arm64": "bun build --compile --minify --target=bun-darwin-arm64 src/main.ts --outfile dist/aimdware-router-macos-arm64", + "build:macos-x64": "bun build --compile --minify --target=bun-darwin-x64 src/main.ts --outfile dist/aimdware-router-macos-x64", + "build:linux-arm64": "bun build --compile --minify --target=bun-linux-arm64 src/main.ts --outfile dist/aimdware-router-linux-arm64", + "build:linux-x64": "bun build --compile --minify --target=bun-linux-x64 src/main.ts --outfile dist/aimdware-router-linux-x64", + "build:windows-x64": "bun build --compile --minify --target=bun-windows-x64 src/main.ts --outfile dist/aimdware-router-windows-x64.exe", + "build:all": "rm -rf dist && bun run build:macos-arm64 && bun run build:macos-x64 && bun run build:linux-arm64 && bun run build:linux-x64 && bun run build:windows-x64" + }, + "dependencies": { + "webdav": "^5.10.0", + "yaml": "^2.5.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "^1.1.10", + "oxfmt": "^0.49.0", + "oxlint": "^1.41.0", + "typescript": "^5.6.3" + } +} diff --git a/llm-client/src/config.test.ts b/llm-client/src/config.test.ts new file mode 100644 index 0000000..5eaf9d9 --- /dev/null +++ b/llm-client/src/config.test.ts @@ -0,0 +1,180 @@ +import { test, expect } from "bun:test"; +import { loadConfig } from "./config"; + +test("loadConfig parses a minimal config", () => { + const yaml = ` +student_token: st_abc123 +course: ECE4721J +assignment: hw1 +upstream: + api_key: sk-test +backend_url: https://aimdware.sjtu.edu +`; + const config = loadConfig(yaml); + + expect(config.student_token).toBe("st_abc123"); + expect(config.course).toBe("ECE4721J"); + expect(config.upstream.api_key).toBe("sk-test"); + expect(config.backend_url).toBe("https://aimdware.sjtu.edu"); +}); + +test("loadConfig applies defaults for optional fields", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + api_key: sk-x +backend_url: https://b.example +`; + const config = loadConfig(yaml); + + expect(config.upstream.base_url).toBe("https://api.openai.com"); + expect(config.port).toBe(12345); + expect(config.local_cache_dir).toBe("~/.cache/aimdware"); + expect(config.jbox_remote_path).toBe("aimdware/ECE4721J/hw1"); +}); + +test("loadConfig rejects missing required fields", () => { + const cases: Array<[string, string]> = [ + [ + "missing student_token", + `course: X\nassignment: hw1\nupstream:\n api_key: k\nbackend_url: u`, + ], + [ + "missing course", + `student_token: t\nassignment: hw1\nupstream:\n api_key: k\nbackend_url: u`, + ], + [ + "missing assignment", + `student_token: t\ncourse: X\nupstream:\n api_key: k\nbackend_url: u`, + ], + [ + "missing upstream.api_key", + `student_token: t\ncourse: X\nassignment: hw1\nupstream: {}\nbackend_url: u`, + ], + [ + "missing backend_url", + `student_token: t\ncourse: X\nassignment: hw1\nupstream:\n api_key: k`, + ], + ]; + for (const [label, yaml] of cases) { + expect(() => loadConfig(yaml), label).toThrow(); + } +}); + +test("loadConfig rejects course and assignment values the backend would reject", () => { + const cases: Array<[string, string]> = [ + ["course with slash", "course: ECE/4721J\nassignment: hw1"], + ["assignment with space", "course: ECE4721J\nassignment: hw 1"], + ["assignment with non-ascii", "course: ECE4721J\nassignment: 作业1"], + ]; + for (const [label, fields] of cases) { + const yaml = ` +student_token: st_x +${fields} +upstream: + api_key: sk-x +backend_url: https://b.example +`; + expect(() => loadConfig(yaml), label).toThrow(); + } +}); + +test("loadConfig rejects jbox_remote_path that disagrees with course and assignment", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +jbox_remote_path: aimdware/OTHER/hw2 +upstream: + api_key: sk-x +backend_url: https://b.example +`; + expect(() => loadConfig(yaml)).toThrow(); +}); + +test("loadConfig defaults upstream.type to 'openai'", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + api_key: sk-x +backend_url: https://b.example +`; + const config = loadConfig(yaml); + expect(config.upstream.type).toBe("openai"); +}); + +test("loadConfig parses an explicit upstream.type", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + type: openai + api_key: sk-x +backend_url: https://b.example +`; + const config = loadConfig(yaml); + expect(config.upstream.type).toBe("openai"); +}); + +test("loadConfig parses subscription plugins without an api_key", () => { + for (const plugin of ["codex", "copilot"] as const) { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + plugin: ${plugin} +backend_url: https://b.example +`; + const config = loadConfig(yaml); + expect(config.upstream.plugin).toBe(plugin); + expect(config.upstream.type).toBe(plugin); + expect(config.upstream.api_key).toBeUndefined(); + } +}); + +test("loadConfig keeps upstream.type as a backward-compatible plugin alias", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + type: copilot +backend_url: https://b.example +`; + const config = loadConfig(yaml); + expect(config.upstream.plugin).toBe("copilot"); + expect(config.upstream.type).toBe("copilot"); +}); + +test("loadConfig rejects api-key OpenAI upstreams without an api_key", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + plugin: openai +backend_url: https://b.example +`; + expect(() => loadConfig(yaml)).toThrow("upstream.api_key is required"); +}); + +test("loadConfig rejects conflicting upstream.type and upstream.plugin", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +assignment: hw1 +upstream: + type: codex + plugin: copilot +backend_url: https://b.example +`; + expect(() => loadConfig(yaml)).toThrow( + "upstream.type and upstream.plugin must match", + ); +}); diff --git a/llm-client/src/config.ts b/llm-client/src/config.ts new file mode 100644 index 0000000..1485dec --- /dev/null +++ b/llm-client/src/config.ts @@ -0,0 +1,82 @@ +import YAML from "yaml"; +import { z } from "zod"; + +const SlugSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_.-]+$/, "must contain only A-Z, a-z, 0-9, _, ., or -"); + +const UpstreamSchema = z + .object({ + type: z.enum(["openai", "codex", "copilot"]).optional(), + plugin: z.enum(["openai", "codex", "copilot"]).optional(), + base_url: z.string().optional(), + api_key: z.string().optional(), + model: z.string().optional(), + }) + .superRefine((value, ctx) => { + if ( + value.type !== undefined && + value.plugin !== undefined && + value.type !== value.plugin + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["plugin"], + message: "upstream.type and upstream.plugin must match", + }); + } + const plugin = value.plugin ?? value.type ?? "openai"; + if (plugin === "openai" && !value.api_key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["api_key"], + message: "upstream.api_key is required", + }); + } + }) + .transform((value) => { + const plugin = value.plugin ?? value.type ?? "openai"; + return { + ...value, + type: plugin, + plugin, + base_url: value.base_url ?? "https://api.openai.com", + api_key: value.api_key, + }; + }); + +const RawConfigSchema = z.object({ + student_token: z.string().min(1, "student_token is required"), + course: SlugSchema, + assignment: SlugSchema, + upstream: UpstreamSchema, + port: z.number().int().positive().default(12345), + local_cache_dir: z.string().default("~/.cache/aimdware"), + jbox_remote_path: z.string().optional(), + backend_url: z.string().min(1, "backend_url is required"), + tbox_url: z.string().default("http://127.0.0.1:8089"), + tbox_user: z.string().default(""), + tbox_pass: z.string().default(""), +}); + +export type Config = z.infer & { + jbox_remote_path: string; +}; + +export function loadConfig(yamlText: string): Config { + const raw = YAML.parse(yamlText); + const parsed = RawConfigSchema.parse(raw); + const canonicalJboxPath = `aimdware/${parsed.course}/${parsed.assignment}`; + if ( + parsed.jbox_remote_path !== undefined && + parsed.jbox_remote_path !== canonicalJboxPath + ) { + throw new Error(`jbox_remote_path must be ${canonicalJboxPath}`); + } + return { + ...parsed, + jbox_remote_path: parsed.jbox_remote_path ?? canonicalJboxPath, + }; +} diff --git a/llm-client/src/http/handler.test.ts b/llm-client/src/http/handler.test.ts new file mode 100644 index 0000000..c5a9dcf --- /dev/null +++ b/llm-client/src/http/handler.test.ts @@ -0,0 +1,305 @@ +import { test, expect, afterEach, beforeEach } from "bun:test"; +import type { Server } from "bun"; +import { createHandler } from "./handler"; +import type { CaptureResult } from "../recording/capture"; +import { createCodexProvider } from "../providers/codex"; +import type { AuthStore } from "../providers/auth-store"; + +let fakeUpstream: Server | undefined; + +afterEach(async () => { + await fakeUpstream?.stop(true); + fakeUpstream = undefined; +}); + +beforeEach(() => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.ALL_PROXY; + delete process.env.NO_PROXY; +}); + +function startFakeUpstream( + responder: (req: Request) => Promise | Response, +): { baseUrl: string } { + fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: responder, + }); + return { baseUrl: `http://127.0.0.1:${fakeUpstream.port}` }; +} + +const loggedInCodexStore: AuthStore = { + async get() { + return { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }; + }, + async set() {}, + async del() {}, +}; + +test("GET /healthz returns 200 ok", async () => { + const handler = createHandler({ + upstream: { base_url: "https://unused", api_key: "x" }, + }); + const res = await handler(new Request("http://localhost/healthz")); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); +}); + +test("unknown path returns 404", async () => { + const handler = createHandler({ + upstream: { base_url: "https://unused", api_key: "x" }, + }); + const res = await handler(new Request("http://localhost/nope")); + expect(res.status).toBe(404); +}); + +test("POST /v1/chat/completions forwards request to upstream", async () => { + const seen: { body: string; auth: string | null } = { body: "", auth: null }; + const { baseUrl } = startFakeUpstream(async (req) => { + seen.body = await req.text(); + seen.auth = req.headers.get("authorization"); + return new Response('{"id":"x"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "sk-upstream" }, + }); + + const res = await handler( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}', + }), + ); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('{"id":"x"}'); + const parsedBody = JSON.parse(seen.body); + expect(parsedBody.model).toBe("gpt-4o"); + expect(seen.auth).toBe("Bearer sk-upstream"); +}); + +test("POST /v1/responses forwards Responses API request to upstream", async () => { + const seen: { body: string; auth: string | null } = { body: "", auth: null }; + const { baseUrl } = startFakeUpstream(async (req) => { + seen.body = await req.text(); + seen.auth = req.headers.get("authorization"); + return new Response('{"id":"resp_x"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "sk-upstream" }, + }); + + const res = await handler( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-5","input":[{"role":"user","content":"hi"}]}', + }), + ); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('{"id":"resp_x"}'); + expect(JSON.parse(seen.body).input[0].content).toBe("hi"); + expect(seen.auth).toBe("Bearer sk-upstream"); +}); + +test("POST /v1/chat/completions returns 400 for Responses-only providers", async () => { + const handler = createHandler({ + upstream: createCodexProvider({ authStore: loggedInCodexStore }), + }); + + const res = await handler( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-5.3-codex","messages":[]}', + }), + ); + + expect(res.status).toBe(400); + expect(await res.text()).toContain("use /v1/responses"); +}); + +test("POST /v1/chat/completions fires onCapture in background with full blob", async () => { + const { baseUrl } = startFakeUpstream( + () => + new Response('{"id":"y","model":"gpt-4o"}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + let captured: CaptureResult | undefined; + const captureDone = new Promise((resolve) => { + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "sk-x" }, + onCapture: (r) => { + captured = r; + resolve(); + }, + }); + void handler( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-4o","messages":[]}', + }), + ); + }); + await captureDone; + + expect(captured).toBeDefined(); + expect(captured!.upstream_status).toBe(200); + expect(new TextDecoder().decode(captured!.request_bytes)).toContain( + '"model":"gpt-4o"', + ); + const resp = JSON.parse(new TextDecoder().decode(captured!.response_bytes)); + expect(resp).toEqual({ id: "y", model: "gpt-4o" }); +}); + +test("POST /v1/responses fires onCapture in background with full blob", async () => { + const { baseUrl } = startFakeUpstream( + () => + new Response('{"id":"resp_y","model":"gpt-5"}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + let captured: CaptureResult | undefined; + const captureDone = new Promise((resolve) => { + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "sk-x" }, + onCapture: (r) => { + captured = r; + resolve(); + }, + }); + void handler( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-5","input":[{"role":"user","content":"hi"}]}', + }), + ); + }); + await captureDone; + + expect(captured).toBeDefined(); + expect(captured!.upstream_status).toBe(200); + expect(new TextDecoder().decode(captured!.request_bytes)).toContain( + '"input"', + ); + const resp = JSON.parse(new TextDecoder().decode(captured!.response_bytes)); + expect(resp).toEqual({ id: "resp_y", model: "gpt-5" }); +}); + +test("/v1/chat/completions streams response while still capturing", async () => { + const chunks = ["data: a\n\n", "data: b\n\n", "data: [DONE]\n\n"]; + const { baseUrl } = startFakeUpstream(() => { + const stream = new ReadableStream({ + async start(ctrl) { + const enc = new TextEncoder(); + for (const c of chunks) { + ctrl.enqueue(enc.encode(c)); + await Bun.sleep(2); + } + ctrl.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + let captured: CaptureResult | undefined; + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "sk-x" }, + onCapture: (r) => { + captured = r; + }, + }); + + const res = await handler( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-4o","stream":true,"messages":[]}', + }), + ); + + expect(await res.text()).toBe(chunks.join("")); + + // capture happens after the stream completes — give the microtask a beat + await Bun.sleep(10); + expect(captured).toBeDefined(); + expect(new TextDecoder().decode(captured!.response_bytes)).toBe( + chunks.join(""), + ); +}); + +test("onCapture throwing is caught and logged, request still completes", async () => { + const { baseUrl } = startFakeUpstream( + () => + new Response('{"id":"x"}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const errors: string[] = []; + const origError = console.error; + let resolveLogged!: () => void; + const logged = new Promise((resolve) => { + resolveLogged = resolve; + }); + console.error = (...args) => { + errors.push(args.join(" ")); + if (args.join(" ").includes("onCapture failed")) resolveLogged(); + }; + + try { + const handler = createHandler({ + upstream: { base_url: baseUrl, api_key: "k" }, + onCapture: async () => { + throw new Error("simulated outbox-write failure"); + }, + }); + const res = await handler( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"model":"gpt-4o","messages":[]}', + }), + ); + expect(res.status).toBe(200); // client-side success unaffected + + await Promise.race([ + logged, + Bun.sleep(1000).then(() => { + throw new Error("timed out waiting for onCapture error log"); + }), + ]); + expect(errors.join("\n")).toContain("onCapture failed"); + expect(errors.join("\n")).toContain("simulated outbox-write failure"); + } finally { + console.error = origError; + } +}); diff --git a/llm-client/src/http/handler.ts b/llm-client/src/http/handler.ts new file mode 100644 index 0000000..35e9715 --- /dev/null +++ b/llm-client/src/http/handler.ts @@ -0,0 +1,111 @@ +import { captureChat, type CaptureResult } from "../recording/capture"; +import { + proxyChat, + proxyResponses, + type FetchLike, + type UpstreamConfig, +} from "./proxy"; +import { + UnsupportedProviderProtocolError, + type ProviderRuntime, +} from "../providers/plugin"; + +export type HandlerOpts = { + upstream: UpstreamConfig | ProviderRuntime; + onCapture?: (result: CaptureResult) => void | Promise; + fetchImpl?: FetchLike; +}; + +export type RequestHandler = (req: Request) => Promise; + +export function createHandler(opts: HandlerOpts): RequestHandler { + return async (req) => { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/healthz") { + return new Response("ok", { status: 200 }); + } + + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + return handleProviderErrors(() => handleChat(req, opts)); + } + + if (req.method === "POST" && url.pathname === "/v1/responses") { + return handleProviderErrors(() => handleResponses(req, opts)); + } + + return new Response("not found", { status: 404 }); + }; +} + +async function handleProviderErrors( + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof UnsupportedProviderProtocolError) { + return new Response(e.message, { status: 400 }); + } + throw e; + } +} + +async function handleChat(req: Request, opts: HandlerOpts): Promise { + return handleCaptured(req, opts, (proxyReq) => + proxyChat(proxyReq, opts.upstream, { + fetchImpl: opts.fetchImpl, + }), + ); +} + +async function handleResponses( + req: Request, + opts: HandlerOpts, +): Promise { + return handleCaptured(req, opts, (proxyReq) => + proxyResponses(proxyReq, opts.upstream, { + fetchImpl: opts.fetchImpl, + }), + ); +} + +async function handleCaptured( + req: Request, + opts: HandlerOpts, + proxy: (req: Request) => Promise, +): Promise { + const requestBytes = new Uint8Array(await req.arrayBuffer()); + + // The proxy reads from a Request; rebuild one carrying the body we just + // captured so capture and proxy each have their own bytes. + const proxyReq = new Request(req.url, { + method: req.method, + headers: req.headers, + body: requestBytes, + }); + + const upstreamRes = await proxy(proxyReq); + + const { clientResponse, captureP } = captureChat(requestBytes, upstreamRes); + + captureP.then( + async (result) => { + // onCapture is the integration point that writes to the local + // outbox + cache. If anything in there throws (sqlite locked, disk + // full, queue.db corrupt), we MUST log it — otherwise the record + // silently disappears from the audit trail (client already saw + // the response and is happy). + try { + await opts.onCapture?.(result); + } catch (err) { + console.error("onCapture failed:", err); + } + }, + (err) => { + console.error("capture failed:", err); + }, + ); + + return clientResponse; +} diff --git a/llm-client/src/http/net.test.ts b/llm-client/src/http/net.test.ts new file mode 100644 index 0000000..2f9ac2c --- /dev/null +++ b/llm-client/src/http/net.test.ts @@ -0,0 +1,109 @@ +import { test, expect, beforeEach } from "bun:test"; +import { getProxyForUrl } from "./net"; + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +]; + +function clearProxyEnv() { + for (const k of PROXY_ENV_KEYS) delete process.env[k]; +} + +beforeEach(clearProxyEnv); + +test("returns undefined when no proxy env is set", () => { + expect(getProxyForUrl("https://api.openai.com")).toBeUndefined(); +}); + +test("HTTPS_PROXY matches https:// URLs", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + expect(getProxyForUrl("https://api.openai.com")).toBe("http://corp:8080"); +}); + +test("HTTP_PROXY matches http:// URLs", () => { + process.env.HTTP_PROXY = "http://corp:8080"; + expect(getProxyForUrl("http://example.com")).toBe("http://corp:8080"); +}); + +test("HTTPS_PROXY does not match http:// URLs", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + expect(getProxyForUrl("http://example.com")).toBeUndefined(); +}); + +test("ALL_PROXY is the fallback for any scheme", () => { + process.env.ALL_PROXY = "socks5://fallback:1080"; + expect(getProxyForUrl("https://api.openai.com")).toBe( + "socks5://fallback:1080", + ); + expect(getProxyForUrl("http://example.com")).toBe("socks5://fallback:1080"); +}); + +test("scheme-specific env beats ALL_PROXY", () => { + process.env.ALL_PROXY = "socks5://fallback:1080"; + process.env.HTTPS_PROXY = "http://specific:8080"; + expect(getProxyForUrl("https://api.openai.com")).toBe("http://specific:8080"); +}); + +test("lowercase env vars are accepted", () => { + process.env.https_proxy = "http://lower:8080"; + expect(getProxyForUrl("https://api.openai.com")).toBe("http://lower:8080"); +}); + +test("uppercase env vars beat lowercase if both set", () => { + process.env.https_proxy = "http://lower:8080"; + process.env.HTTPS_PROXY = "http://upper:8080"; + expect(getProxyForUrl("https://api.openai.com")).toBe("http://upper:8080"); +}); + +test("NO_PROXY exact host match disables proxy", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.NO_PROXY = "api.openai.com"; + expect(getProxyForUrl("https://api.openai.com")).toBeUndefined(); +}); + +test("NO_PROXY host:port match disables proxy", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.NO_PROXY = "auth.openai.com:443,github.com:443"; + expect(getProxyForUrl("https://auth.openai.com/oauth/token")).toBeUndefined(); + expect( + getProxyForUrl("https://github.com/login/device/code"), + ).toBeUndefined(); + expect(getProxyForUrl("https://api.openai.com")).toBe("http://corp:8080"); +}); + +test("NO_PROXY suffix match (.example.com matches sub.example.com)", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.NO_PROXY = ".example.com"; + expect(getProxyForUrl("https://sub.example.com")).toBeUndefined(); + expect(getProxyForUrl("https://example.com")).toBeUndefined(); + expect(getProxyForUrl("https://other.com")).toBe("http://corp:8080"); +}); + +test("NO_PROXY bare domain also matches subdomains", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.NO_PROXY = "example.com"; + expect(getProxyForUrl("https://sub.example.com")).toBeUndefined(); + expect(getProxyForUrl("https://example.com")).toBeUndefined(); + expect(getProxyForUrl("https://other.com")).toBe("http://corp:8080"); +}); + +test("NO_PROXY * disables all proxies", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.NO_PROXY = "*"; + expect(getProxyForUrl("https://api.openai.com")).toBeUndefined(); +}); + +test("localhost / 127.0.0.1 are never proxied by default", () => { + process.env.HTTPS_PROXY = "http://corp:8080"; + process.env.HTTP_PROXY = "http://corp:8080"; + expect(getProxyForUrl("http://localhost:5000")).toBeUndefined(); + expect(getProxyForUrl("http://127.0.0.1:5000")).toBeUndefined(); + expect(getProxyForUrl("http://[::1]:5000")).toBeUndefined(); +}); diff --git a/llm-client/src/http/net.ts b/llm-client/src/http/net.ts new file mode 100644 index 0000000..5a3c521 --- /dev/null +++ b/llm-client/src/http/net.ts @@ -0,0 +1,69 @@ +/** + * Resolve the outbound proxy URL (if any) for a given target URL, honoring + * HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY environment variables. + * + * Precedence: + * 1. Localhost / loopback (`localhost`, `127.0.0.1`, `::1`) -> never proxied. + * 2. NO_PROXY entries -> never proxied (exact host, `.suffix`, or `*`). + * 3. Scheme-specific (HTTPS_PROXY for https://, HTTP_PROXY for http://). + * 4. ALL_PROXY as fallback. + * + * Uppercase env vars beat lowercase if both are set. + */ +export function getProxyForUrl(url: string | URL): string | undefined { + const u = typeof url === "string" ? new URL(url) : url; + const host = stripIpv6Brackets(u.hostname.toLowerCase()); + + if (isLoopback(host)) return undefined; + + const noProxy = readEnv("NO_PROXY", "no_proxy"); + if (noProxy && matchesNoProxy(host, noProxy)) return undefined; + + if (u.protocol === "https:") { + const p = readEnv("HTTPS_PROXY", "https_proxy"); + if (p) return p; + } else if (u.protocol === "http:") { + const p = readEnv("HTTP_PROXY", "http_proxy"); + if (p) return p; + } + + return readEnv("ALL_PROXY", "all_proxy"); +} + +function readEnv(upper: string, lower: string): string | undefined { + const v = process.env[upper] ?? process.env[lower]; + return v && v.length > 0 ? v : undefined; +} + +function stripIpv6Brackets(host: string): string { + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; +} + +function isLoopback(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "::1"; +} + +function matchesNoProxy(host: string, noProxy: string): boolean { + const entries = noProxy + .split(",") + .map((s) => normalizeNoProxyEntry(s.trim().toLowerCase())) + .filter(Boolean); + for (const entry of entries) { + if (entry === "*") return true; + if (entry.startsWith(".")) { + // ".example.com" matches "sub.example.com" and "example.com" + const suffix = entry.slice(1); + if (host === suffix || host.endsWith(entry)) return true; + } else if (host === entry || host.endsWith(`.${entry}`)) { + return true; + } + } + return false; +} + +function normalizeNoProxyEntry(entry: string): string { + if (entry.startsWith("[") && entry.includes("]")) { + return entry.slice(1, entry.indexOf("]")); + } + return entry.replace(/:\d+$/, ""); +} diff --git a/llm-client/src/http/proxy.test.ts b/llm-client/src/http/proxy.test.ts new file mode 100644 index 0000000..5c64507 --- /dev/null +++ b/llm-client/src/http/proxy.test.ts @@ -0,0 +1,274 @@ +import { test, expect, afterEach, beforeEach } from "bun:test"; +import type { Server } from "bun"; +import { proxyChat, proxyResponses, type FetchLike } from "./proxy"; + +beforeEach(() => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.ALL_PROXY; + delete process.env.NO_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.all_proxy; + delete process.env.no_proxy; +}); + +type RecordedRequest = { + url: string; + method: string; + headers: Record; + body: string; +}; + +let fakeUpstream: Server | undefined; +const recorded: RecordedRequest[] = []; + +function startFakeUpstream( + handler: (req: Request) => Promise | Response, +): Promise<{ baseUrl: string }> { + return new Promise((resolve) => { + fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const body = await req.text(); + const headers: Record = {}; + req.headers.forEach((v, k) => { + headers[k] = v; + }); + recorded.push({ url: req.url, method: req.method, headers, body }); + // Rebuild Request because we consumed the body + const replay = new Request(req.url, { + method: req.method, + headers: req.headers, + body: body || undefined, + }); + return handler(replay); + }, + }); + resolve({ baseUrl: `http://127.0.0.1:${fakeUpstream.port}` }); + }); +} + +afterEach(async () => { + await fakeUpstream?.stop(true); + fakeUpstream = undefined; + recorded.length = 0; +}); + +test("proxyChat forwards body and rewrites Authorization to upstream's api_key", async () => { + const { baseUrl } = await startFakeUpstream( + () => + new Response('{"id":"x"}', { + headers: { "content-type": "application/json" }, + }), + ); + + const inbound = new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer router-side-junk", + }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + }), + }); + + await proxyChat(inbound, { base_url: baseUrl, api_key: "sk-upstream" }); + + expect(recorded).toHaveLength(1); + expect(recorded[0]!.url).toBe(`${baseUrl}/v1/chat/completions`); + expect(recorded[0]!.method).toBe("POST"); + expect(recorded[0]!.headers.authorization).toBe("Bearer sk-upstream"); + const fwd = JSON.parse(recorded[0]!.body); + expect(fwd.model).toBe("gpt-4o"); + expect(fwd.messages[0].content).toBe("hi"); +}); + +test("proxyChat returns upstream status and body verbatim for non-stream", async () => { + const { baseUrl } = await startFakeUpstream( + () => + new Response('{"echo":"ok"}', { + status: 201, + headers: { "content-type": "application/json" }, + }), + ); + + const res = await proxyChat( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "m", messages: [] }), + }), + { base_url: baseUrl, api_key: "sk-x" }, + ); + + expect(res.status).toBe(201); + expect(await res.text()).toBe('{"echo":"ok"}'); +}); + +test("proxyChat passes proxy from HTTPS_PROXY env to fetch", async () => { + const calls: Array<{ url: string; init: RequestInit & { proxy?: string } }> = + []; + const mockFetch: FetchLike = async (input, init) => { + calls.push({ + url: typeof input === "string" ? input : (input as URL).toString(), + init: (init ?? {}) as RequestInit & { proxy?: string }, + }); + return new Response("ok"); + }; + + process.env.HTTPS_PROXY = "http://corp:8080"; + + await proxyChat( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + { base_url: "https://api.openai.com", api_key: "sk-x" }, + { fetchImpl: mockFetch }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(calls[0]!.init.proxy).toBe("http://corp:8080"); +}); + +test("proxyChat does not set proxy for loopback upstream", async () => { + const calls: Array<{ init: RequestInit & { proxy?: string } }> = []; + const mockFetch: FetchLike = async (_input, init) => { + calls.push({ init: (init ?? {}) as RequestInit & { proxy?: string } }); + return new Response("ok"); + }; + + process.env.HTTPS_PROXY = "http://corp:8080"; + + await proxyChat( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + body: "{}", + }), + { base_url: "http://127.0.0.1:11434", api_key: "any" }, + { fetchImpl: mockFetch }, + ); + + expect(calls[0]!.init.proxy).toBeUndefined(); +}); + +test("proxyChat relays an SSE stream chunk-by-chunk", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n', + "data: [DONE]\n\n", + ]; + const { baseUrl } = await startFakeUpstream(() => { + const stream = new ReadableStream({ + async start(ctrl) { + const enc = new TextEncoder(); + for (const c of chunks) { + ctrl.enqueue(enc.encode(c)); + await Bun.sleep(5); + } + ctrl.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const res = await proxyChat( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "m", messages: [], stream: true }), + }), + { base_url: baseUrl, api_key: "sk-x" }, + ); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const body = await res.text(); + expect(body).toBe(chunks.join("")); +}); + +test("proxyChat preserves base_url path prefix (SJTU-style /api/v1)", async () => { + // SJTU gateway is at https://models.sjtu.edu.cn/api/v1 — base_url has + // a path component. Naive `new URL(absolute, base)` would drop it. + const prefixCalls: Array<{ url: string }> = []; + const fake = async (url: URL | string) => { + prefixCalls.push({ url: typeof url === "string" ? url : url.toString() }); + return new Response('{"ok":true}', { status: 200 }); + }; + + await proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + body: '{"model":"x","messages":[]}', + headers: { "content-type": "application/json" }, + }), + { base_url: "https://example.com/api/v1", api_key: "k" }, + { fetchImpl: fake as FetchLike }, + ); + + expect(prefixCalls[0]!.url).toBe( + "https://example.com/api/v1/chat/completions", + ); +}); + +test("proxyChat does not double-prefix /v1 when base_url already ends with /v1", async () => { + const dedupeCalls: Array<{ url: string }> = []; + const fake = async (url: URL | string) => { + dedupeCalls.push({ url: typeof url === "string" ? url : url.toString() }); + return new Response("{}", { status: 200 }); + }; + + await proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + body: "{}", + }), + { base_url: "https://api.openai.com/v1", api_key: "k" }, + { fetchImpl: fake as FetchLike }, + ); + + expect(dedupeCalls[0]!.url).toBe( + "https://api.openai.com/v1/chat/completions", + ); +}); + +test("proxyResponses forwards Responses API requests to OpenAI-compatible upstreams", async () => { + const { baseUrl } = await startFakeUpstream( + () => + new Response('{"id":"resp_x"}', { + headers: { "content-type": "application/json" }, + }), + ); + + await proxyResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer router-side-junk", + }, + body: JSON.stringify({ + model: "gpt-5", + input: [{ role: "user", content: "hi" }], + }), + }), + { base_url: baseUrl, api_key: "sk-upstream" }, + ); + + expect(recorded).toHaveLength(1); + expect(recorded[0]!.url).toBe(`${baseUrl}/v1/responses`); + expect(recorded[0]!.headers.authorization).toBe("Bearer sk-upstream"); + const fwd = JSON.parse(recorded[0]!.body); + expect(fwd.model).toBe("gpt-5"); + expect(fwd.input[0].content).toBe("hi"); +}); diff --git a/llm-client/src/http/proxy.ts b/llm-client/src/http/proxy.ts new file mode 100644 index 0000000..bfa6f32 --- /dev/null +++ b/llm-client/src/http/proxy.ts @@ -0,0 +1,95 @@ +import { getProxyForUrl } from "./net"; +import { createOpenAIProvider } from "../providers/openai"; +import type { ProviderRuntime } from "../providers/plugin"; + +export type UpstreamConfig = { + base_url: string; + api_key: string; +}; + +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit & { proxy?: string }, +) => Promise; + +export type ProxyChatOpts = { + fetchImpl?: FetchLike; +}; + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", // recomputed by fetch +]); + +export async function proxyChat( + inbound: Request, + upstream: UpstreamConfig | ProviderRuntime, + opts: ProxyChatOpts = {}, +): Promise { + return proxyPrepared(inbound, upstream, "chat", opts); +} + +export async function proxyResponses( + inbound: Request, + upstream: UpstreamConfig | ProviderRuntime, + opts: ProxyChatOpts = {}, +): Promise { + return proxyPrepared(inbound, upstream, "responses", opts); +} + +async function proxyPrepared( + inbound: Request, + upstream: UpstreamConfig | ProviderRuntime, + protocol: "chat" | "responses", + opts: ProxyChatOpts, +): Promise { + const inboundUrl = new URL(inbound.url); + + const forwardedHeaders = new Headers(); + inbound.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + forwardedHeaders.set(key, value); + } + }); + + const body = + inbound.method === "GET" || inbound.method === "HEAD" + ? undefined + : await inbound.arrayBuffer(); + const provider = + "prepareChat" in upstream ? upstream : createOpenAIProvider(upstream); + const prepare = + protocol === "chat" ? provider.prepareChat : provider.prepareResponses; + const prepared = await prepare({ + inboundUrl, + method: inbound.method, + headers: forwardedHeaders, + body, + }); + + const proxy = getProxyForUrl(prepared.url); + const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike); + + const init: RequestInit & { proxy?: string } = { + method: prepared.method ?? inbound.method, + headers: prepared.headers, + body: body === undefined ? undefined : (prepared.body ?? body), + }; + if (proxy !== undefined) init.proxy = proxy; + + const upstreamRes = await f(prepared.url, init); + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: upstreamRes.headers, + }); +} diff --git a/llm-client/src/http/server.test.ts b/llm-client/src/http/server.test.ts new file mode 100644 index 0000000..a10736a --- /dev/null +++ b/llm-client/src/http/server.test.ts @@ -0,0 +1,38 @@ +import { test, expect, afterEach } from "bun:test"; +import { startServer, type ServerHandle } from "./server"; + +let handle: ServerHandle | undefined; + +afterEach(async () => { + await handle?.stop(); + handle = undefined; +}); + +test("startServer delegates fetch to the provided handler", async () => { + handle = await startServer( + { port: 0, hostname: "127.0.0.1" }, + async (req) => + new Response(`echo: ${new URL(req.url).pathname}`, { status: 200 }), + ); + + const res = await fetch(`http://127.0.0.1:${handle.port}/anything`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("echo: /anything"); +}); + +test("server binds to the requested hostname", async () => { + handle = await startServer( + { port: 0, hostname: "127.0.0.1" }, + () => new Response("ok"), + ); + expect(handle.hostname).toBe("127.0.0.1"); +}); + +test("server.stop is idempotent", async () => { + handle = await startServer( + { port: 0, hostname: "127.0.0.1" }, + () => new Response("ok"), + ); + await handle.stop(); + await handle.stop(); +}); diff --git a/llm-client/src/http/server.ts b/llm-client/src/http/server.ts new file mode 100644 index 0000000..2c51d48 --- /dev/null +++ b/llm-client/src/http/server.ts @@ -0,0 +1,33 @@ +export type ServerOptions = { + port: number; + hostname: string; +}; + +export type ServerHandle = { + port: number; + hostname: string; + stop: () => Promise; +}; + +export type RequestHandler = (req: Request) => Promise | Response; + +export async function startServer( + opts: ServerOptions, + handler: RequestHandler, +): Promise { + const server = Bun.serve({ + port: opts.port, + hostname: opts.hostname, + fetch: handler, + }); + if (server.port === undefined) { + throw new Error("server.port unexpectedly undefined (unix socket?)"); + } + return { + port: server.port, + hostname: opts.hostname, + stop: async () => { + await server.stop(true); + }, + }; +} diff --git a/llm-client/src/main-extract.test.ts b/llm-client/src/main-extract.test.ts new file mode 100644 index 0000000..5f33dda --- /dev/null +++ b/llm-client/src/main-extract.test.ts @@ -0,0 +1,35 @@ +import { test, expect } from "bun:test"; +import { extractMessages } from "./main"; + +const enc = new TextEncoder(); + +test("extractMessages reads Chat Completions messages", () => { + const messages = extractMessages( + enc.encode( + JSON.stringify({ + messages: [{ role: "user", content: "hi" }], + }), + ), + ); + + expect(messages).toEqual([{ role: "user", content: "hi" }]); +}); + +test("extractMessages reads Responses input items", () => { + const messages = extractMessages( + enc.encode( + JSON.stringify({ + input: [ + { + role: "user", + content: [{ type: "input_text", text: "hi" }], + }, + ], + }), + ), + ); + + expect(messages).toEqual([ + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); +}); diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts new file mode 100644 index 0000000..1e4bcd0 --- /dev/null +++ b/llm-client/src/main-sync.test.ts @@ -0,0 +1,186 @@ +/** + * Tests the sync stage's interaction with the session-keyed cache file. + * + * The redesign keeps ONE file per session on disk (overwritten on each + * turn). When the worker fires the sync stage for turn N, the file may + * already contain turn N+1's bytes (a newer turn beat the worker). We + * accept that — the latest state is what we want on jbox — but it has + * to be tested, otherwise a refactor could silently break it and only + * the real-Tbox bash smoke would catch it. + */ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildSyncStage } from "./main"; +import type { WebDAVPutLike } from "./outbox/sync"; +import type { IngestBody } from "./outbox/ingest-client"; +import { writeAtomic } from "./util"; + +const tmpDirs: string[] = []; +function fresh() { + const d = mkdtempSync(join(tmpdir(), "aimdware-mainsync-")); + tmpDirs.push(d); + const cacheDir = join(d, "cache"); + mkdirSync(join(cacheDir, "records"), { recursive: true }); + return { cacheDir }; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +function recordingPut(): { + put: WebDAVPutLike; + uploads: Array<{ path: string; bytes: Uint8Array }>; +} { + const uploads: Array<{ path: string; bytes: Uint8Array }> = []; + const put: WebDAVPutLike = async (path, bytes) => { + uploads.push({ path, bytes: new Uint8Array(bytes) }); + }; + return { put, uploads }; +} + +function body( + record_id: string, + session_id: string, + turn_count: number, +): IngestBody { + return { + record_id, + session_id, + turn_count, + course_code: "X", + assignment: "hw1", + blob_hash: "abc", + blob_uri: `aimdware/X/hw1/${session_id}.json`, + blob_size: 0, + ts: new Date(0).toISOString(), + router_version: "0.0.0", + }; +} + +test("sync stage reads .json (NOT .json) and PUTs those bytes", async () => { + const { cacheDir } = fresh(); + const { put, uploads } = recordingPut(); + const bytes = new TextEncoder().encode("turn-1-blob"); + await writeAtomic(join(cacheDir, "records", "S1.json"), bytes); + + const stage = buildSyncStage(cacheDir, put); + const result = await stage(body("r1", "S1", 1)); + + expect(result).toEqual({ kind: "advance" }); + expect(uploads).toHaveLength(1); + expect(uploads[0]!.path).toBe("/aimdware/X/hw1/S1.json"); + expect(new TextDecoder().decode(uploads[0]!.bytes)).toBe("turn-1-blob"); +}); + +test("sync stage uploads WHATEVER is currently on disk — a later turn's overwrite wins", async () => { + const { cacheDir } = fresh(); + const { put, uploads } = recordingPut(); + const filePath = join(cacheDir, "records", "S-race.json"); + const stage = buildSyncStage(cacheDir, put); + + // Capture turn 1 — write its bytes. + await writeAtomic(filePath, new TextEncoder().encode("turn-1")); + + // Capture turn 2 — overwrites the file BEFORE the worker fires turn 1's sync. + await writeAtomic(filePath, new TextEncoder().encode("turn-2-final")); + + // Worker fires sync for turn 1 — reads disk (now turn-2 bytes), PUTs them. + const r1 = await stage(body("r1", "S-race", 1)); + expect(r1).toEqual({ kind: "advance" }); + expect(new TextDecoder().decode(uploads[0]!.bytes)).toBe("turn-2-final"); + + // Worker fires sync for turn 2 — reads same disk, PUTs same bytes. + const r2 = await stage(body("r2", "S-race", 2)); + expect(r2).toEqual({ kind: "advance" }); + expect(new TextDecoder().decode(uploads[1]!.bytes)).toBe("turn-2-final"); + + // BOTH PUTs went to the same session-keyed jbox path. + expect(uploads[0]!.path).toBe(uploads[1]!.path); + expect(uploads[0]!.path).toBe("/aimdware/X/hw1/S-race.json"); +}); + +test("sync stage in parallel: 3 PUTs for one session all carry the latest disk bytes", async () => { + const { cacheDir } = fresh(); + const { put, uploads } = recordingPut(); + const filePath = join(cacheDir, "records", "S-par.json"); + await writeAtomic(filePath, new TextEncoder().encode("final-state")); + + const stage = buildSyncStage(cacheDir, put); + await Promise.all([ + stage(body("r1", "S-par", 1)), + stage(body("r2", "S-par", 2)), + stage(body("r3", "S-par", 3)), + ]); + expect(uploads).toHaveLength(3); + for (const u of uploads) { + expect(new TextDecoder().decode(u.bytes)).toBe("final-state"); + } +}); + +test("sync stage returns terminal/fatal when the session cache file is gone", async () => { + const { cacheDir } = fresh(); + const { put, uploads } = recordingPut(); + const stage = buildSyncStage(cacheDir, put); + + const r = await stage(body("r1", "S-missing", 1)); + expect(r.kind).toBe("terminal"); + if (r.kind === "terminal") expect(r.finalState).toBe("fatal"); + expect(uploads).toHaveLength(0); +}); + +test("sync stage routes WebDAV errors through the result kind", async () => { + const { cacheDir } = fresh(); + await writeAtomic( + join(cacheDir, "records", "S-err.json"), + new TextEncoder().encode("x"), + ); + + // 500 -> retryable + const flaky500: WebDAVPutLike = async () => { + throw Object.assign(new Error("upstream blew up"), { status: 500 }); + }; + const r500 = await buildSyncStage(cacheDir, flaky500)(body("r1", "S-err", 1)); + expect(r500.kind).toBe("retry"); + + // 401 -> fatal + const auth401: WebDAVPutLike = async () => { + throw Object.assign(new Error("nope"), { status: 401 }); + }; + const r401 = await buildSyncStage(cacheDir, auth401)(body("r2", "S-err", 1)); + expect(r401.kind).toBe("terminal"); +}); + +test("sync stage uploads a 2 MB blob without truncation or corruption", async () => { + // Realistic agent-platform scenario: 1 MB tools schema + 1 MB user + // context produces a multi-megabyte cache file. Sync must round-trip + // it intact to the (fake here) WebDAV. + const { cacheDir } = fresh(); + const { put, uploads } = recordingPut(); + const filePath = join(cacheDir, "records", "S-big.json"); + + // Build ~2 MB of deterministic content. + const block = "abcdefghijklmnopqrstuvwxyz".repeat(40); + const big = block.repeat(50_000); // ~52 MB → trim to 2 MB + const blob = new TextEncoder().encode(big.slice(0, 2_000_000)); + await writeAtomic(filePath, blob); + + const t0 = performance.now(); + const r = await buildSyncStage(cacheDir, put)(body("r1", "S-big", 1)); + const dt = performance.now() - t0; + + expect(r).toEqual({ kind: "advance" }); + expect(uploads).toHaveLength(1); + expect(uploads[0]!.bytes.byteLength).toBe(2_000_000); + + // Verify byte-identity: a single differing byte would fail audit. + const sent = uploads[0]!.bytes; + expect(sent[0]).toBe(blob[0]); + expect(sent[1_999_999]).toBe(blob[1_999_999]); + expect(Buffer.from(sent).equals(Buffer.from(blob))).toBe(true); + + // <500ms even on a 2 MB read + memcpy + PUT-callback. + expect(dt).toBeLessThan(500); +}); diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts new file mode 100644 index 0000000..edef7f3 --- /dev/null +++ b/llm-client/src/main.test.ts @@ -0,0 +1,181 @@ +import { test, expect, afterAll } from "bun:test"; +import { spawn, type Subprocess } from "bun"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const procs: Subprocess[] = []; +const tmpDirs: string[] = []; + +afterAll(async () => { + for (const p of procs) p.kill(); + for (const d of tmpDirs) await rm(d, { recursive: true, force: true }); +}); + +async function waitForPort(port: number, host = "127.0.0.1", timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://${host}:${port}/healthz`); + if (res.status === 200) return; + } catch { + /* not ready */ + } + await Bun.sleep(50); + } + throw new Error(`port ${port} not ready within ${timeoutMs}ms`); +} + +function pickFreePort(): Promise { + return new Promise((resolve) => { + const s = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(""), + }); + const port = s.port!; + s.stop(true).then(() => resolve(port)); + }); +} + +test("main: serves /healthz and proxies a chat completion end-to-end", async () => { + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => + new Response('{"id":"upstream-ok"}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + + const routerPort = await pickFreePort(); + + const tmp = await mkdtemp(join(tmpdir(), "aimdware-e2e-")); + tmpDirs.push(tmp); + const configPath = join(tmp, "aimdware.yaml"); + await writeFile( + configPath, + ` +student_token: st_test +course: ECE4721J +assignment: hw1 +upstream: + base_url: http://127.0.0.1:${fakeUpstream.port} + api_key: sk-test +port: ${routerPort} +local_cache_dir: ${tmp}/cache +backend_url: http://127.0.0.1:1 +`, + ); + + const proc = spawn({ + cmd: ["bun", "run", "src/main.ts", "--config", configPath], + cwd: import.meta.dir + "/..", + stdout: "pipe", + stderr: "pipe", + }); + procs.push(proc); + + try { + await waitForPort(routerPort); + + const res = await fetch( + `http://127.0.0.1:${routerPort}/v1/chat/completions`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + }), + }, + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe('{"id":"upstream-ok"}'); + } finally { + proc.kill(); + await fakeUpstream.stop(true); + } +}); + +test("main: never prints plaintext student_token or upstream api_key", async () => { + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response('{"id":"x"}', { status: 200 }), + }); + const routerPort = await pickFreePort(); + const tmp = await mkdtemp(join(tmpdir(), "aimdware-redact-")); + tmpDirs.push(tmp); + + const STUDENT = "st_DO_NOT_LOG_ME_THIS_IS_LONG_AND_OBVIOUS_zzzz"; + const APIKEY = "sk-DO_NOT_LOG_ME_EITHER_zzzz"; + + const configPath = join(tmp, "aimdware.yaml"); + await writeFile( + configPath, + ` +student_token: ${STUDENT} +course: ECE4721J +assignment: hw1 +upstream: + base_url: http://127.0.0.1:${fakeUpstream.port} + api_key: ${APIKEY} +port: ${routerPort} +local_cache_dir: ${tmp}/cache +backend_url: http://127.0.0.1:1 +`, + ); + + const proc = spawn({ + cmd: ["bun", "run", "src/main.ts", "--config", configPath], + cwd: import.meta.dir + "/..", + stdout: "pipe", + stderr: "pipe", + }); + procs.push(proc); + + // Drain stdout/stderr CONCURRENTLY — otherwise the pipe fills and the + // child blocks on console.log. + const stdoutChunks: Uint8Array[] = []; + const stderrChunks: Uint8Array[] = []; + const drainStdout = (async () => { + for await (const chunk of proc.stdout as ReadableStream) { + stdoutChunks.push(chunk); + } + })(); + const drainStderr = (async () => { + for await (const chunk of proc.stderr as ReadableStream) { + stderrChunks.push(chunk); + } + })(); + + try { + await waitForPort(routerPort); + + await fetch(`http://127.0.0.1:${routerPort}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-4o", messages: [] }), + }).then((r) => r.text()); + + await Bun.sleep(100); + proc.kill(); + await Promise.race([proc.exited, Bun.sleep(2000)]); + await Promise.all([drainStdout, drainStderr]); + + const decode = (cs: Uint8Array[]) => + Buffer.concat(cs.map((c) => Buffer.from(c))).toString("utf-8"); + const combined = decode(stdoutChunks) + decode(stderrChunks); + + expect(combined).not.toContain(STUDENT); + expect(combined).not.toContain(APIKEY); + // Sanity: the redacted prefix should appear (proves we did log + // *something* about the token). + expect(combined).toContain(STUDENT.slice(0, 8)); + expect(combined).toContain(APIKEY.slice(0, 8)); + } finally { + await fakeUpstream.stop(true); + } +}); diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts new file mode 100644 index 0000000..01216ce --- /dev/null +++ b/llm-client/src/main.ts @@ -0,0 +1,354 @@ +#!/usr/bin/env bun +import { parseArgs } from "node:util"; +import { readFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { loadConfig, type Config } from "./config"; +import { createHandler } from "./http/handler"; +import { startServer } from "./http/server"; +import { authFilePath, createFileAuthStore } from "./providers/auth-store"; +import type { AuthStore } from "./providers/auth-store"; +import { loginCodexDevice, loginCopilotDevice } from "./providers/auth-login"; +import { createProvider } from "./providers"; +import { IngestQueue } from "./outbox/queue"; +import { + startWorkerLoop, + type Stages, + type StageHandler, +} from "./outbox/relay"; +import { + postContext, + confirmUploaded, + type IngestBody, +} from "./outbox/ingest-client"; +import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./outbox/sync"; +import { writeAtomic, bytesToHex, redactToken, sessionBlobPath } from "./util"; +import { + PendingSessionWrites, + runSessionCacheCleanupOnce, + startEvictionLoop, +} from "./outbox/eviction"; +import { tryParseJSON, decodeBytes } from "./recording/capture"; +import { SessionTracker, type Message } from "./recording/session"; +import { buildSessionBlob } from "./recording/session-blob"; +import pkg from "../package.json" with { type: "json" }; + +const ROUTER_VERSION = pkg.version; + +function expandHome(p: string): string { + return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; +} + +export function extractMessages(requestBytes: Uint8Array): Message[] { + const parsed = tryParseJSON(decodeBytes(requestBytes)); + if (parsed && typeof parsed === "object") { + const messages = (parsed as { messages?: unknown }).messages; + if (Array.isArray(messages)) return messages as Message[]; + const input = (parsed as { input?: unknown }).input; + if (Array.isArray(input)) return input as Message[]; + } + return []; +} + +async function runAuthCommand( + positionals: string[], + authStore: AuthStore, + enterpriseUrl: string | undefined, +): Promise { + if (positionals[0] !== "auth") return false; + const action = positionals[1]; + const provider = positionals[2]; + + if (action === "status") { + for (const id of ["codex", "copilot"] as const) { + const auth = await authStore.get(id); + if (!auth) { + console.log(`${id}: not logged in`); + continue; + } + console.log( + `${id}: logged in token=${redactToken(auth.access ?? auth.refresh)}`, + ); + } + return true; + } + + if (action === "login" && provider === "codex") { + await loginCodexDevice({ authStore }); + console.log("codex: logged in"); + return true; + } + + if (action === "login" && provider === "copilot") { + await loginCopilotDevice({ authStore, enterpriseUrl }); + console.log("copilot: logged in"); + return true; + } + + throw new Error( + "unknown auth command; expected `auth status`, `auth login codex`, or `auth login copilot`", + ); +} + +/** + * Read the session's cached blob from disk and PUT it to jbox. The file + * is shared across all turns of the session — if a newer turn overwrote + * it before this worker fired, that newer state is what gets uploaded. + * The older turn's `blob_hash` stored in the backend will then fail + * verification against the on-jbox bytes; this is documented in + * `BlobStatus`. + */ +export function buildSyncStage( + cacheDir: string, + webdavPut: WebDAVPutLike, +): StageHandler { + return async (body) => { + const path = sessionBlobPath(cacheDir, body.session_id); + let data: Uint8Array; + try { + data = new Uint8Array(await Bun.file(path).arrayBuffer()); + } catch (e) { + return { + kind: "terminal", + finalState: "fatal", + reason: `cache file missing: ${(e as Error).message}`, + }; + } + const r = await syncBlob(webdavPut, "/" + body.blob_uri, data); + switch (r.kind) { + case "synced": + return { kind: "advance" }; + case "fatal": + return { kind: "terminal", finalState: "fatal", reason: r.reason }; + case "retryable": + return { kind: "retry", reason: r.reason }; + } + }; +} + +function buildStages( + config: Config, + cacheDir: string, + webdavPut: WebDAVPutLike, +): Stages { + const ingest: StageHandler = async (body) => { + const r = await postContext(config.backend_url, config.student_token, body); + switch (r.kind) { + case "created": + case "exists": + return { kind: "advance" }; + case "conflict": + return { + kind: "terminal", + finalState: "conflict", + reason: "body mismatch", + }; + case "fatal": + return { kind: "terminal", finalState: "fatal", reason: r.reason }; + case "retryable": + return { kind: "retry", reason: r.reason }; + } + }; + + const confirm: StageHandler = async (body) => { + const r = await confirmUploaded( + config.backend_url, + config.student_token, + body.record_id, + ); + switch (r.kind) { + case "ok": + return { kind: "advance" }; + case "fatal": + return { kind: "terminal", finalState: "fatal", reason: r.reason }; + case "retryable": + return { kind: "retry", reason: r.reason }; + } + }; + + return { ingest, sync: buildSyncStage(cacheDir, webdavPut), confirm }; +} + +async function main() { + const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + config: { type: "string", short: "c", default: "./aimdware.yaml" }, + help: { type: "boolean", short: "h" }, + "enterprise-url": { type: "string" }, + }, + allowPositionals: true, + }); + + if (values.help) { + console.log(`aimdware-router ${ROUTER_VERSION} + +Usage: + aimdware-router --config start the router + aimdware-router --config auth status + aimdware-router --config auth login codex + aimdware-router --config auth login copilot [--enterprise-url ] + aimdware-router --help show this message + +The config file is a YAML doc. See aimdware.example.yaml for the +expected fields (student_token, course, backend_url, tbox_*, upstream).`); + return; + } + + const configPath = values.config!; + let yamlText: string; + try { + yamlText = await readFile(configPath, "utf-8"); + } catch (e) { + console.error( + `failed to read config at ${configPath}:`, + (e as Error).message, + ); + process.exit(1); + } + + const config = loadConfig(yamlText); + const cacheDir = expandHome(config.local_cache_dir); + await mkdir(join(cacheDir, "records"), { recursive: true }); + const queueDb = join(cacheDir, "queue.db"); + const authStore = createFileAuthStore(authFilePath(cacheDir)); + + try { + if ( + await runAuthCommand(positionals, authStore, values["enterprise-url"]) + ) { + return; + } + } catch (e) { + console.error((e as Error).message); + process.exit(1); + } + + const queue = new IngestQueue(queueDb); + const sessionTracker = new SessionTracker(); + const pendingCacheWrites = new PendingSessionWrites(); + const provider = createProvider(config.upstream, authStore); + const webdavPut = makeWebDAVPut( + config.tbox_url, + config.tbox_user + ? { username: config.tbox_user, password: config.tbox_pass } + : undefined, + ); + + const handler = createHandler({ + upstream: provider, + onCapture: async (result) => { + const messages = extractMessages(result.request_bytes); + const cls = sessionTracker.classify(messages, result.ts); + + const blob = buildSessionBlob({ + session_id: cls.session_id, + course: config.course, + assignment: config.assignment, + started_at: cls.started_at, + latest_ts: result.ts, + turn_count: cls.turn_count, + upstream_type: config.upstream.type, + upstream_status: result.upstream_status, + request_bytes: result.request_bytes, + response_bytes: result.response_bytes, + }); + + const blobPath = sessionBlobPath(cacheDir, cls.session_id); + pendingCacheWrites.begin(cls.session_id); + try { + try { + await writeAtomic(blobPath, blob.blob_bytes); + } catch (e) { + console.error( + `cache write failed for record=${result.record_id} session=${cls.session_id}:`, + (e as Error).message, + ); + return; + } + + const body: IngestBody = { + record_id: result.record_id, + session_id: cls.session_id, + turn_count: cls.turn_count, + course_code: config.course, + assignment: config.assignment, + blob_hash: bytesToHex(blob.blob_hash), + blob_uri: `${config.jbox_remote_path}/${cls.session_id}.json`, + blob_size: blob.blob_size, + ts: result.ts.toISOString(), + router_version: ROUTER_VERSION, + client_meta: { upstream_type: config.upstream.type }, + }; + queue.enqueue(body, Date.now()); + const hex = bytesToHex(blob.blob_hash).slice(0, 16); + console.log( + `captured record=${result.record_id} session=${cls.session_id} turn=${cls.turn_count} hash=${hex}… size=${blob.blob_size} -> queued`, + ); + } finally { + pendingCacheWrites.end(cls.session_id); + } + }, + }); + + const handle = await startServer( + { port: config.port, hostname: "127.0.0.1" }, + handler, + ); + + const worker = startWorkerLoop( + { + queue, + stages: buildStages(config, cacheDir, webdavPut), + concurrency: 4, + afterAdvance: async (body, from, to) => { + if (from === "ingested" && to === "synced") { + await runSessionCacheCleanupOnce({ + queue, + cacheDir, + session_id: body.session_id, + isSessionPending: pendingCacheWrites.has, + }); + } + }, + }, + 1000, + ); + + const eviction = startEvictionLoop({ + queue, + cacheDir, + isSessionPending: pendingCacheWrites.has, + }); + + console.log( + `aimdware-router listening on http://${handle.hostname}:${handle.port}`, + ); + console.log(` upstream: ${provider.label} (${provider.id})`); + if (config.upstream.plugin === "openai") { + console.log(` upstream url: ${config.upstream.base_url}`); + console.log(` upstream key: ${redactToken(config.upstream.api_key)}`); + } else { + console.log(` upstream auth: ${authFilePath(cacheDir)}`); + } + console.log(` student token: ${redactToken(config.student_token)}`); + console.log(` course: ${config.course}`); + console.log(` backend: ${config.backend_url}`); + console.log(` tbox: ${config.tbox_url}`); + console.log(` cache: ${cacheDir}`); + + const shutdown = async (signal: string) => { + console.log(`\n${signal} received, stopping`); + await handle.stop(); + await worker.stop(); + await eviction.stop(); + queue.close(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} + +if (import.meta.main) { + await main(); +} diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts new file mode 100644 index 0000000..85aab5f --- /dev/null +++ b/llm-client/src/outbox/eviction.test.ts @@ -0,0 +1,442 @@ +import { test, expect, afterEach } from "bun:test"; +import { + mkdtempSync, + rmSync, + writeFileSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import { IngestQueue } from "./queue"; +import type { IngestBody } from "./ingest-client"; +import { + PendingSessionWrites, + runEvictionOnce, + runSessionCacheCleanupOnce, +} from "./eviction"; + +const tmpDirs: string[] = []; +function fresh() { + const d = mkdtempSync(join(tmpdir(), "aimdware-evict-")); + tmpDirs.push(d); + const cacheDir = join(d, "cache"); + const recordsDir = join(cacheDir, "records"); + require("node:fs").mkdirSync(recordsDir, { recursive: true }); + const q = new IngestQueue(join(cacheDir, "queue.db")); + return { d, cacheDir, recordsDir, q }; +} + +function body( + record_id: string, + session_id: string, + turn_count = 1, +): IngestBody { + return { + record_id, + session_id, + turn_count, + course_code: "ECE4721J", + assignment: "hw1", + blob_hash: "h", + blob_uri: `jbox://x/${session_id}.json`, + blob_size: 1, + ts: "2026-05-12T00:00:00.000Z", + router_version: "0.0.0", + }; +} + +function backdate(cacheDir: string, record_id: string, createdMs: number) { + const raw = new Database(join(cacheDir, "queue.db")); + raw.exec( + `UPDATE outbox SET created_at = ${createdMs} WHERE record_id = '${record_id}'`, + ); + raw.close(); +} + +function setupDoneTurn( + q: IngestQueue, + cacheDir: string, + recordsDir: string, + record_id: string, + session_id: string, + turn_count: number, + createdMs: number, +): void { + q.enqueue(body(record_id, session_id, turn_count), 0); + q.advance(record_id, "ingested", 0); + q.advance(record_id, "synced", 0); + q.advance(record_id, "done", 0); + backdate(cacheDir, record_id, createdMs); + // The cache file is keyed by SESSION_ID, not record_id, and is shared + // across every turn of the same session. + writeFileSync( + join(recordsDir, `${session_id}.json`), + `payload-${session_id}`, + ); +} + +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +const NOW = 100_000_000; +const TTL = 24 * 3600 * 1000; + +test("pending session writes use refcounts for overlapping captures", () => { + const pending = new PendingSessionWrites(); + + pending.begin("S-overlap"); + pending.begin("S-overlap"); + pending.end("S-overlap"); + expect(pending.has("S-overlap")).toBe(true); + pending.end("S-overlap"); + expect(pending.has("S-overlap")).toBe(false); +}); + +test("evicts a single-turn session past TTL: deletes .json + marks record", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S1", 1, NOW - TTL - 1000); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S1.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + q.close(); +}); + +test("evicts a multi-turn session ONCE: deletes one file, marks all N records", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S-multi", 1, NOW - TTL - 3000); + setupDoneTurn(q, cacheDir, recordsDir, "r2", "S-multi", 2, NOW - TTL - 2000); + setupDoneTurn(q, cacheDir, recordsDir, "r3", "S-multi", 3, NOW - TTL - 1000); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S-multi.json"))).toBe(false); + for (const r of ["r1", "r2", "r3"]) expect(q.isEvicted(r)).toBe(true); + q.close(); +}); + +test("does NOT evict a session that still has in-flight turns", async () => { + const { cacheDir, recordsDir, q } = fresh(); + // r1 is done + old; r2 is still captured (in progress) + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S-live", 1, NOW - TTL - 2000); + q.enqueue(body("r2", "S-live", 2), 0); + backdate(cacheDir, "r2", NOW - TTL - 1000); + // File still in use by the live turn — must NOT be deleted. + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(0); + expect(existsSync(join(recordsDir, "S-live.json"))).toBe(true); + expect(q.isEvicted("r1")).toBe(false); + expect(q.isEvicted("r2")).toBe(false); + q.close(); +}); + +test("evicts terminal failure sessions past TTL", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-terminal", 1), 0); + q.markTerminal("r1", "fatal", "schema bug"); + backdate(cacheDir, "r1", NOW - TTL - 2000); + q.enqueue(body("r2", "S-terminal", 2), 0); + q.markTerminal("r2", "conflict", "body mismatch"); + backdate(cacheDir, "r2", NOW - TTL - 1000); + writeFileSync(join(recordsDir, "S-terminal.json"), "payload"); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S-terminal.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + expect(q.isEvicted("r2")).toBe(true); + q.close(); +}); + +test("does NOT evict when the latest turn is still within TTL", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S-fresh", 1, NOW - 5_000); + setupDoneTurn(q, cacheDir, recordsDir, "r2", "S-fresh", 2, NOW - 1_000); + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(0); + expect(existsSync(join(recordsDir, "S-fresh.json"))).toBe(true); + q.close(); +}); + +test("cleanup deletes a session cache immediately once every turn is synced or later", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-uploaded", 1), 0); + q.advance("r1", "ingested", 0); + q.advance("r1", "synced", 0); + q.enqueue(body("r2", "S-uploaded", 2), 0); + q.advance("r2", "ingested", 0); + q.advance("r2", "synced", 0); + writeFileSync(join(recordsDir, "S-uploaded.json"), "payload"); + + const summary = await runSessionCacheCleanupOnce({ + queue: q, + cacheDir, + session_id: "S-uploaded", + }); + + expect(summary.sessions_evicted).toBe(1); + expect(summary.records_marked).toBe(2); + expect(existsSync(join(recordsDir, "S-uploaded.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + expect(q.isEvicted("r2")).toBe(true); + q.close(); +}); + +test("cleanup skips a reclaimable session while a cache write is pending", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-writing", 1), 0); + q.advance("r1", "ingested", 0); + q.advance("r1", "synced", 0); + writeFileSync(join(recordsDir, "S-writing.json"), "payload"); + const pending = new PendingSessionWrites(); + pending.begin("S-writing"); + pending.begin("S-writing"); + pending.end("S-writing"); + + const skipped = await runSessionCacheCleanupOnce({ + queue: q, + cacheDir, + session_id: "S-writing", + isSessionPending: pending.has, + }); + expect(skipped.sessions_evicted).toBe(0); + expect(existsSync(join(recordsDir, "S-writing.json"))).toBe(true); + expect(q.isEvicted("r1")).toBe(false); + + pending.end("S-writing"); + const cleaned = await runSessionCacheCleanupOnce({ + queue: q, + cacheDir, + session_id: "S-writing", + isSessionPending: pending.has, + }); + expect(cleaned.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S-writing.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + q.close(); +}); + +test("cleanup keeps a session cache while any turn still needs upload", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-pending", 1), 0); + q.advance("r1", "ingested", 0); + q.advance("r1", "synced", 0); + q.enqueue(body("r2", "S-pending", 2), 0); + q.advance("r2", "ingested", 0); + writeFileSync(join(recordsDir, "S-pending.json"), "payload"); + + const summary = await runSessionCacheCleanupOnce({ + queue: q, + cacheDir, + session_id: "S-pending", + }); + + expect(summary.sessions_evicted).toBe(0); + expect(summary.records_marked).toBe(0); + expect(existsSync(join(recordsDir, "S-pending.json"))).toBe(true); + expect(q.isEvicted("r1")).toBe(false); + expect(q.isEvicted("r2")).toBe(false); + q.close(); +}); + +test("ttl eviction cleans up old synced sessions if fast cleanup was missed", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-synced-old", 1), 0); + q.advance("r1", "ingested", 0); + q.advance("r1", "synced", 0); + backdate(cacheDir, "r1", NOW - TTL - 1000); + writeFileSync(join(recordsDir, "S-synced-old.json"), "payload"); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + + expect(summary.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S-synced-old.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + q.close(); +}); + +test("ttl eviction skips an old synced session while a cache write is pending", async () => { + const { cacheDir, recordsDir, q } = fresh(); + q.enqueue(body("r1", "S-ttl-writing", 1), 0); + q.advance("r1", "ingested", 0); + q.advance("r1", "synced", 0); + backdate(cacheDir, "r1", NOW - TTL - 1000); + writeFileSync(join(recordsDir, "S-ttl-writing.json"), "payload"); + const pending = new PendingSessionWrites(); + pending.begin("S-ttl-writing"); + + const skipped = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + isSessionPending: pending.has, + }); + expect(skipped.sessions_evicted).toBe(0); + expect(existsSync(join(recordsDir, "S-ttl-writing.json"))).toBe(true); + expect(q.isEvicted("r1")).toBe(false); + + pending.end("S-ttl-writing"); + const cleaned = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + isSessionPending: pending.has, + }); + expect(cleaned.sessions_evicted).toBe(1); + expect(existsSync(join(recordsDir, "S-ttl-writing.json"))).toBe(false); + expect(q.isEvicted("r1")).toBe(true); + q.close(); +}); + +test("idempotent: a second pass after a successful eviction is a no-op", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S1", 1, NOW - TTL - 1000); + + const first = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(first.sessions_evicted).toBe(1); + const second = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(second.sessions_evicted).toBe(0); + q.close(); +}); + +test("tolerates a missing file (already removed out-of-band) — still marks records evicted", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S-ghost", 1, NOW - TTL - 1000); + rmSync(join(recordsDir, "S-ghost.json")); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(1); + expect(q.isEvicted("r1")).toBe(true); + q.close(); +}); + +test("does NOT mark evicted when unlink fails", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn( + q, + cacheDir, + recordsDir, + "r1", + "S-blocked", + 1, + NOW - TTL - 1000, + ); + rmSync(join(recordsDir, "S-blocked.json")); + mkdirSync(join(recordsDir, "S-blocked.json")); + + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(0); + expect(summary.records_marked).toBe(0); + expect(q.isEvicted("r1")).toBe(false); + expect(warnings.join("\n")).toContain( + "unlink failed for session S-blocked", + ); + } finally { + console.warn = origWarn; + q.close(); + } +}); + +test("limit caps the number of sessions per pass", async () => { + const { cacheDir, recordsDir, q } = fresh(); + for (let i = 0; i < 5; i++) { + setupDoneTurn( + q, + cacheDir, + recordsDir, + `r${i}`, + `S${i}`, + 1, + NOW - TTL - 1000 - i, + ); + } + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + limit: 2, + }); + expect(summary.sessions_evicted).toBe(2); + q.close(); +}); + +test("evicts multiple distinct sessions in one pass", async () => { + const { cacheDir, recordsDir, q } = fresh(); + setupDoneTurn(q, cacheDir, recordsDir, "r1", "S-a", 1, NOW - TTL - 1000); + setupDoneTurn(q, cacheDir, recordsDir, "r2", "S-b", 1, NOW - TTL - 2000); + setupDoneTurn(q, cacheDir, recordsDir, "r3", "S-c", 1, NOW - 1_000); // fresh + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); + expect(summary.sessions_evicted).toBe(2); + expect(existsSync(join(recordsDir, "S-a.json"))).toBe(false); + expect(existsSync(join(recordsDir, "S-b.json"))).toBe(false); + expect(existsSync(join(recordsDir, "S-c.json"))).toBe(true); + q.close(); +}); diff --git a/llm-client/src/outbox/eviction.ts b/llm-client/src/outbox/eviction.ts new file mode 100644 index 0000000..f19d312 --- /dev/null +++ b/llm-client/src/outbox/eviction.ts @@ -0,0 +1,151 @@ +import { unlink } from "node:fs/promises"; +import type { IngestQueue } from "./queue"; +import { StoppableSleep, sessionBlobPath } from "../util"; + +export type EvictionOpts = { + queue: IngestQueue; + cacheDir: string; + now?: () => number; + ttlMs?: number; + isSessionPending?: (session_id: string) => boolean; + /** Max sessions to process per pass (not records). */ + limit?: number; +}; + +export type EvictionSummary = { + sessions_evicted: number; + records_marked: number; +}; + +const DEFAULT_TTL_MS = 24 * 3600 * 1000; +const DEFAULT_LIMIT = 500; + +export class PendingSessionWrites { + private counts = new Map(); + + begin(session_id: string): void { + this.counts.set(session_id, (this.counts.get(session_id) ?? 0) + 1); + } + + end(session_id: string): void { + const next = (this.counts.get(session_id) ?? 0) - 1; + if (next > 0) this.counts.set(session_id, next); + else this.counts.delete(session_id); + } + + has = (session_id: string): boolean => { + return this.counts.has(session_id); + }; +} + +/** + * Run one eviction pass over the cache directory. + * + * The local cache file is keyed by `session_id` (shared across every + * turn of an agent run), so eviction operates session-by-session, not + * record-by-record. For each terminal session past TTL: unlink + * `records/.json` once and mark every constituent record + * `cache_evicted = 1`. ENOENT is tolerated (the file may have been + * cleared out-of-band). + * + * The queue rows themselves are never deleted — they remain as a local + * audit trail. Eventual queue cleanup is a separate admin concern. + */ +export async function runEvictionOnce( + opts: EvictionOpts, +): Promise { + const now = (opts.now ?? Date.now)(); + const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; + const limit = opts.limit ?? DEFAULT_LIMIT; + const threshold = now - ttlMs; + + return evictSessions( + opts.queue, + opts.cacheDir, + opts.queue.findEvictableSessions(threshold, limit), + opts.isSessionPending, + ); +} + +export type SessionCacheCleanupOpts = { + queue: IngestQueue; + cacheDir: string; + session_id: string; + isSessionPending?: (session_id: string) => boolean; +}; + +/** + * Delete one session's local blob as soon as no queued record can still + * need to upload it. This is the fast path after WebDAV PUT succeeds; + * the periodic TTL eviction remains as a fallback for old terminal rows. + */ +export async function runSessionCacheCleanupOnce( + opts: SessionCacheCleanupOpts, +): Promise { + const session = opts.queue.findReclaimableSession(opts.session_id); + return evictSessions( + opts.queue, + opts.cacheDir, + session ? [session] : [], + opts.isSessionPending, + ); +} + +async function evictSessions( + queue: IngestQueue, + cacheDir: string, + sessions: Array<{ session_id: string; record_ids: string[] }>, + isSessionPending?: (session_id: string) => boolean, +): Promise { + let sessions_evicted = 0; + let records_marked = 0; + for (const s of sessions) { + if (isSessionPending?.(s.session_id)) continue; + let canMarkEvicted = true; + try { + await unlink(sessionBlobPath(cacheDir, s.session_id)); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + canMarkEvicted = false; + console.warn( + `unlink failed for session ${s.session_id}:`, + (e as Error).message, + ); + } + } + if (!canMarkEvicted) continue; + for (const rid of s.record_ids) queue.markEvicted(rid); + sessions_evicted += 1; + records_marked += s.record_ids.length; + } + return { sessions_evicted, records_marked }; +} + +export type EvictionLoopHandle = { stop: () => Promise }; + +export function startEvictionLoop( + opts: EvictionOpts, + pollMs = 30 * 60 * 1000, // every 30 minutes +): EvictionLoopHandle { + const sleeper = new StoppableSleep(); + let stopped = false; + const done = (async () => { + while (!stopped) { + try { + await runEvictionOnce(opts); + } catch (e) { + console.error("eviction tick failed:", (e as Error).message); + } + if (stopped) break; + await sleeper.sleep(pollMs); + } + })(); + return { + async stop() { + stopped = true; + sleeper.stop(); + await done; + }, + }; +} diff --git a/llm-client/src/outbox/ingest-client.test.ts b/llm-client/src/outbox/ingest-client.test.ts new file mode 100644 index 0000000..2cef348 --- /dev/null +++ b/llm-client/src/outbox/ingest-client.test.ts @@ -0,0 +1,158 @@ +import { test, expect, afterEach } from "bun:test"; +import type { Server } from "bun"; +import { + postContext, + type IngestBody, + type PostContextResult, +} from "./ingest-client"; + +let fakeBackend: Server | undefined; +let lastReq: { body: string; auth: string | null; url: string } | undefined; + +function startFakeBackend(status: number, responseBody = ""): string { + fakeBackend = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + lastReq = { + url: req.url, + auth: req.headers.get("authorization"), + body: await req.text(), + }; + return new Response(responseBody, { + status, + headers: { "content-type": "application/json" }, + }); + }, + }); + return `http://127.0.0.1:${fakeBackend.port}`; +} + +afterEach(async () => { + await fakeBackend?.stop(true); + fakeBackend = undefined; + lastReq = undefined; +}); + +function sampleBody(): IngestBody { + return { + record_id: "11111111-1111-1111-1111-111111111111", + session_id: "22222222-2222-2222-2222-222222222222", + turn_count: 1, + course_code: "ECE4721J", + assignment: "hw1", + blob_hash: "de".repeat(32), + blob_uri: "aimdware/ECE4721J/hw1/22222222-2222-2222-2222-222222222222.json", + blob_size: 123, + model: "gpt-4o-mini", + prompt_tokens: 14, + completion_tokens: 3, + ts: "2026-05-11T10:00:00.000Z", + router_version: "0.0.0", + client_meta: { agent: "cline" }, + }; +} + +test("postContext sends Bearer student token + JSON body to /ingest/context", async () => { + const url = startFakeBackend(202); + await postContext(url, "st_alpha", sampleBody()); + + expect(lastReq!.url).toBe(`${url}/ingest/context`); + expect(lastReq!.auth).toBe("Bearer st_alpha"); + const sent = JSON.parse(lastReq!.body); + expect(sent.record_id).toBe("11111111-1111-1111-1111-111111111111"); + expect(sent.course_code).toBe("ECE4721J"); + expect(sent.blob_hash).toBe("de".repeat(32)); +}); + +test("202 -> created", async () => { + const url = startFakeBackend(202); + const r = await postContext(url, "st_x", sampleBody()); + expect(r).toEqual({ + kind: "created", + record_id: "11111111-1111-1111-1111-111111111111", + }); +}); + +test("200 -> exists (idempotent replay)", async () => { + const url = startFakeBackend(200); + const r = await postContext(url, "st_x", sampleBody()); + expect(r).toEqual({ + kind: "exists", + record_id: "11111111-1111-1111-1111-111111111111", + }); +}); + +test("409 -> conflict (body mismatch, never retry)", async () => { + const url = startFakeBackend(409); + const r = await postContext(url, "st_x", sampleBody()); + expect(r.kind).toBe("conflict"); +}); + +test("401 / 403 / 400 -> fatal (auth or schema bug, never retry)", async () => { + for (const status of [400, 401, 403, 422]) { + const url = startFakeBackend(status); + const r = await postContext(url, "st_x", sampleBody()); + expect(r.kind).toBe("fatal"); + if (r.kind === "fatal") expect(r.status).toBe(status); + await fakeBackend?.stop(true); + fakeBackend = undefined; + } +}); + +test("503 / 500 / 429 -> retryable", async () => { + for (const status of [429, 500, 502, 503]) { + const url = startFakeBackend(status); + const r = await postContext(url, "st_x", sampleBody()); + expect(r.kind).toBe("retryable"); + if (r.kind === "retryable") expect(r.status).toBe(status); + await fakeBackend?.stop(true); + fakeBackend = undefined; + } +}); + +test("network error -> retryable", async () => { + // unreachable port + const r = await postContext("http://127.0.0.1:1", "st_x", sampleBody()); + expect(r.kind).toBe("retryable"); +}); + +// -------- confirmUploaded -------- +import { confirmUploaded } from "./ingest-client"; + +test("confirmUploaded POSTs to /ingest/context/{id}/uploaded with bearer auth", async () => { + const url = startFakeBackend(202); + await confirmUploaded(url, "st_alpha", "abc-123"); + expect(lastReq!.url).toBe(`${url}/ingest/context/abc-123/uploaded`); + expect(lastReq!.auth).toBe("Bearer st_alpha"); +}); + +test("confirmUploaded: 200 / 202 -> ok", async () => { + for (const status of [200, 202]) { + const url = startFakeBackend(status); + const r = await confirmUploaded(url, "st_x", "id"); + expect(r.kind).toBe("ok"); + await fakeBackend?.stop(true); + fakeBackend = undefined; + } +}); + +test("confirmUploaded: 5xx / 429 -> retryable; 4xx -> fatal", async () => { + for (const status of [500, 503, 429]) { + const url = startFakeBackend(status); + expect((await confirmUploaded(url, "st_x", "id")).kind).toBe("retryable"); + await fakeBackend?.stop(true); + fakeBackend = undefined; + } + for (const status of [400, 401, 404]) { + const url = startFakeBackend(status); + expect((await confirmUploaded(url, "st_x", "id")).kind).toBe("fatal"); + await fakeBackend?.stop(true); + fakeBackend = undefined; + } +}); + +test("confirmUploaded network error -> retryable", async () => { + const r = await confirmUploaded("http://127.0.0.1:1", "st_x", "id"); + expect(r.kind).toBe("retryable"); +}); diff --git a/llm-client/src/outbox/ingest-client.ts b/llm-client/src/outbox/ingest-client.ts new file mode 100644 index 0000000..33d1b6c --- /dev/null +++ b/llm-client/src/outbox/ingest-client.ts @@ -0,0 +1,122 @@ +import { getProxyForUrl } from "../http/net"; +import type { FetchLike } from "../http/proxy"; + +export type IngestBody = { + record_id: string; + session_id: string; + turn_count: number; + course_code: string; + assignment: string; + blob_hash: string; + blob_uri: string; + blob_size: number; + model?: string; + prompt_tokens?: number; + completion_tokens?: number; + ts: string; + router_version: string; + client_meta?: Record; +}; + +export type PostContextResult = + | { kind: "created"; record_id: string } + | { kind: "exists"; record_id: string } + | { kind: "conflict" } + | { kind: "retryable"; status: number; reason: string } + | { kind: "fatal"; status: number; reason: string }; + +export type PostContextOpts = { + fetchImpl?: FetchLike; +}; + +export type ConfirmResult = + | { kind: "ok" } + | { kind: "retryable"; status: number; reason: string } + | { kind: "fatal"; status: number; reason: string }; + +export async function confirmUploaded( + backendUrl: string, + studentToken: string, + recordId: string, + opts: PostContextOpts = {}, +): Promise { + const target = new URL( + `/ingest/context/${encodeURIComponent(recordId)}/uploaded`, + backendUrl, + ); + const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike); + const proxy = getProxyForUrl(target); + let res: Response; + try { + const init: RequestInit & { proxy?: string } = { + method: "POST", + headers: { authorization: `Bearer ${studentToken}` }, + }; + if (proxy !== undefined) init.proxy = proxy; + res = await f(target, init); + } catch (e) { + return { kind: "retryable", status: 0, reason: (e as Error).message }; + } + + if (res.status === 200 || res.status === 202) return { kind: "ok" }; + if (res.status >= 500 || res.status === 429) { + return { + kind: "retryable", + status: res.status, + reason: `backend returned ${res.status}`, + }; + } + return { + kind: "fatal", + status: res.status, + reason: `backend returned ${res.status}`, + }; +} + +export async function postContext( + backendUrl: string, + studentToken: string, + body: IngestBody, + opts: PostContextOpts = {}, +): Promise { + const target = new URL("/ingest/context", backendUrl); + const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike); + const proxy = getProxyForUrl(target); + + let res: Response; + try { + const init: RequestInit & { proxy?: string } = { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${studentToken}`, + }, + body: JSON.stringify(body), + }; + if (proxy !== undefined) init.proxy = proxy; + res = await f(target, init); + } catch (e) { + return { + kind: "retryable", + status: 0, + reason: (e as Error).message, + }; + } + + if (res.status === 202) return { kind: "created", record_id: body.record_id }; + if (res.status === 200) return { kind: "exists", record_id: body.record_id }; + if (res.status === 409) return { kind: "conflict" }; + if (res.status >= 500 || res.status === 429) { + return { + kind: "retryable", + status: res.status, + reason: `backend returned ${res.status}`, + }; + } + // 4xx other (auth, schema, etc.) + return { + kind: "fatal", + status: res.status, + reason: `backend returned ${res.status}`, + }; +} diff --git a/llm-client/src/outbox/queue.multiproc.test.ts b/llm-client/src/outbox/queue.multiproc.test.ts new file mode 100644 index 0000000..b286209 --- /dev/null +++ b/llm-client/src/outbox/queue.multiproc.test.ts @@ -0,0 +1,127 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { IngestQueue } from "./queue"; +import type { IngestBody } from "./ingest-client"; + +const tmpDirs: string[] = []; + +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +function body(id: string): IngestBody { + return { + record_id: id, + session_id: `sess-${id}`, + turn_count: 1, + course_code: "ECE4721J", + assignment: "hw1", + blob_hash: "h", + blob_uri: `jbox://x/${id}.json`, + blob_size: 1, + ts: "2026-05-13T00:00:00.000Z", + router_version: "0.0.0", + }; +} + +async function spawnClaimer( + scriptPath: string, + dbPath: string, + limit: number, +): Promise { + const proc = Bun.spawn(["bun", scriptPath, dbPath, String(limit)], { + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) throw new Error(`claimer exited ${code}: ${err}`); + return JSON.parse(out) as string[]; +} + +test("two processes calling claim() on the same db get disjoint record_ids", async () => { + const dir = mkdtempSync(join(tmpdir(), "aimdware-multiproc-")); + tmpDirs.push(dir); + const dbPath = join(dir, "queue.db"); + + const q = new IngestQueue(dbPath); + for (let i = 0; i < 200; i++) { + q.enqueue(body(`r${i.toString().padStart(3, "0")}`), 0); + } + q.close(); + + const queueModule = join(import.meta.dir, "queue.ts"); + const workerScript = join(dir, "claimer.ts"); + writeFileSync( + workerScript, + `import { IngestQueue } from ${JSON.stringify(queueModule)}; +const q = new IngestQueue(process.argv[2]); +const claimed = q.claim(Date.now(), parseInt(process.argv[3], 10)); +process.stdout.write(JSON.stringify(claimed.map((c) => c.body.record_id))); +q.close(); +`, + ); + + const [idsA, idsB] = await Promise.all([ + spawnClaimer(workerScript, dbPath, 100), + spawnClaimer(workerScript, dbPath, 100), + ]); + + // Union covers all 200, intersection is empty — atomic claim. + const both = [...idsA, ...idsB].sort(); + const expected = Array.from( + { length: 200 }, + (_, i) => `r${i.toString().padStart(3, "0")}`, + ); + expect(both).toEqual(expected); + + const setB = new Set(idsB); + const overlap = idsA.filter((id) => setB.has(id)); + expect(overlap).toEqual([]); + + // SQLite serializes writers, so one process gets the full batch and the other gets the remainder. + expect(idsA.length + idsB.length).toBe(200); + expect(idsA.length).toBeGreaterThan(0); + expect(idsB.length).toBeGreaterThan(0); +}); + +test("three processes racing on a smaller pool — disjoint claims, exactly one wins each row", async () => { + const dir = mkdtempSync(join(tmpdir(), "aimdware-multiproc-")); + tmpDirs.push(dir); + const dbPath = join(dir, "queue.db"); + + const q = new IngestQueue(dbPath); + for (let i = 0; i < 30; i++) q.enqueue(body(`x${i}`), 0); + q.close(); + + const queueModule = join(import.meta.dir, "queue.ts"); + const workerScript = join(dir, "claimer.ts"); + writeFileSync( + workerScript, + `import { IngestQueue } from ${JSON.stringify(queueModule)}; +const q = new IngestQueue(process.argv[2]); +const claimed = q.claim(Date.now(), parseInt(process.argv[3], 10)); +process.stdout.write(JSON.stringify(claimed.map((c) => c.body.record_id))); +q.close(); +`, + ); + + const results = await Promise.all([ + spawnClaimer(workerScript, dbPath, 20), + spawnClaimer(workerScript, dbPath, 20), + spawnClaimer(workerScript, dbPath, 20), + ]); + + const all = results.flat(); + // Every record claimed exactly once. + expect(all.sort()).toEqual( + Array.from({ length: 30 }, (_, i) => `x${i}`).sort(), + ); + expect(new Set(all).size).toBe(30); +}); diff --git a/llm-client/src/outbox/queue.test.ts b/llm-client/src/outbox/queue.test.ts new file mode 100644 index 0000000..6c4195b --- /dev/null +++ b/llm-client/src/outbox/queue.test.ts @@ -0,0 +1,263 @@ +import { test, expect, afterEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { IngestQueue } from "./queue"; +import type { IngestBody } from "./ingest-client"; + +const tmpDirs: string[] = []; + +function freshDb(): { path: string; q: IngestQueue } { + const dir = mkdtempSync(join(tmpdir(), "aimdware-queue-")); + tmpDirs.push(dir); + const path = join(dir, "queue.db"); + return { path, q: new IngestQueue(path) }; +} + +function body(id: string): IngestBody { + return { + record_id: id, + session_id: `sess-${id}`, + turn_count: 1, + course_code: "ECE4721J", + assignment: "hw1", + blob_hash: "h", + blob_uri: `jbox://x/${id}.json`, + blob_size: 1, + ts: "2026-05-11T00:00:00.000Z", + router_version: "0.0.0", + }; +} + +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +test("enqueue starts in 'captured' state", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + expect(q.statusOf("r1")?.state).toBe("captured"); + q.close(); +}); + +test("pickReady returns active records past their next_attempt_at", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 100); + expect(q.pickReady(50, 10)).toHaveLength(0); + const r = q.pickReady(100, 10); + expect(r).toHaveLength(1); + expect(r[0]!.body.record_id).toBe("r1"); + expect(r[0]!.state).toBe("captured"); + q.close(); +}); + +test("advance moves through the lifecycle and resets attempts", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + + q.markRetry("r1", "blip", 100); // attempts=1 + expect(q.statusOf("r1")?.attempts).toBe(1); + + q.advance("r1", "ingested", 100); + let s = q.statusOf("r1")!; + expect(s.state).toBe("ingested"); + expect(s.attempts).toBe(0); + expect(s.last_error).toBeNull(); + + q.advance("r1", "synced", 200); + expect(q.statusOf("r1")?.state).toBe("synced"); + + q.advance("r1", "done", 300); + expect(q.statusOf("r1")?.state).toBe("done"); + expect(q.pickReady(1000, 10)).toHaveLength(0); + q.close(); +}); + +test("markRetry keeps state and bumps attempts + next_attempt_at", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + q.markRetry("r1", "503", 5000); + expect(q.pickReady(1000, 10)).toHaveLength(0); + expect(q.pickReady(5000, 10)).toHaveLength(1); + const s = q.statusOf("r1")!; + expect(s.state).toBe("captured"); + expect(s.attempts).toBe(1); + expect(s.last_error).toBe("503"); + q.close(); +}); + +test("markTerminal: conflict and fatal remove from pickReady", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.markTerminal("a", "conflict", "body mismatch"); + q.markTerminal("b", "fatal", "401"); + expect(q.pickReady(1000, 10)).toHaveLength(0); + expect(q.statusOf("a")?.state).toBe("conflict"); + expect(q.statusOf("b")?.state).toBe("fatal"); + q.close(); +}); + +test("enqueue is idempotent on duplicate record_id", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + q.enqueue(body("r1"), 100); + expect(q.pickReady(1000, 10)).toHaveLength(1); + q.close(); +}); + +test("opening a legacy queue.db (no session_id column) auto-migrates without throwing", () => { + // Simulate a queue.db left over from a pre-session_id router build. + const dir = mkdtempSync(join(tmpdir(), "aimdware-queue-legacy-")); + tmpDirs.push(dir); + const path = join(dir, "queue.db"); + const legacy = new Database(path); + legacy.exec(` + CREATE TABLE outbox ( + record_id TEXT PRIMARY KEY, + body_json TEXT NOT NULL, + state TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL, + last_error TEXT, + created_at INTEGER NOT NULL, + cache_evicted INTEGER NOT NULL DEFAULT 0, + claimed_at INTEGER + ); + INSERT INTO outbox (record_id, body_json, state, attempts, next_attempt_at, created_at) + VALUES ('legacy-1', '{}', 'done', 0, 0, 0); + `); + legacy.close(); + + // Opening with the new code must not throw, and must add session_id. + const q = new IngestQueue(path); + const cols = ( + q as unknown as { + db: { prepare: (s: string) => { all: () => Array<{ name: string }> } }; + } + ).db + .prepare("PRAGMA table_info(outbox)") + .all(); + expect(cols.some((c) => c.name === "session_id")).toBe(true); + expect(q.statusOf("legacy-1")?.state).toBe("done"); + q.close(); +}); + +test("state survives reopening the db file", () => { + const { path, q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.advance("a", "ingested", 0); + q.advance("a", "synced", 0); + q.close(); + + const q2 = new IngestQueue(path); + expect(q2.statusOf("a")?.state).toBe("synced"); + expect(q2.statusOf("b")?.state).toBe("captured"); + q2.close(); +}); + +test("pickReady respects limit + orders ascending by next_attempt_at", () => { + const { q } = freshDb(); + q.enqueue(body("c"), 30); + q.enqueue(body("a"), 10); + q.enqueue(body("b"), 20); + const ready = q.pickReady(1000, 2); + expect(ready.map((r) => r.body.record_id)).toEqual(["a", "b"]); + q.close(); +}); + +test("pickReady returns records in any active state (captured / ingested / synced)", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.enqueue(body("c"), 0); + q.advance("b", "ingested", 0); + q.advance("c", "ingested", 0); + q.advance("c", "synced", 0); + + const ready = q.pickReady(1000, 10); + const byState = Object.fromEntries( + ready.map((r) => [r.body.record_id, r.state]), + ); + expect(byState).toEqual({ a: "captured", b: "ingested", c: "synced" }); + q.close(); +}); + +// --- claim() (atomic single-flight) --- + +test("claim returns ready records and marks them claimed_at = now", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + const claimed = q.claim(1000, 10); + expect(claimed.map((r) => r.body.record_id).sort()).toEqual(["a", "b"]); + expect(q.statusOf("a")?.claimed_at).toBe(1000); + expect(q.statusOf("b")?.claimed_at).toBe(1000); + q.close(); +}); + +test("claim does not return records currently held by another claim", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + expect(q.claim(1000, 10)).toHaveLength(2); + // A second worker calling claim immediately gets nothing. + expect(q.claim(1500, 10)).toHaveLength(0); + q.close(); +}); + +test("claim re-claims stale claims (claimed_at older than staleMs)", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.claim(1000, 10, 60_000); + // Within stale window — still claimed. + expect(q.claim(1000 + 30_000, 10, 60_000)).toHaveLength(0); + // Past stale window — re-claimable. + expect(q.claim(1000 + 61_000, 10, 60_000)).toHaveLength(1); + q.close(); +}); + +test("advance / markRetry / markTerminal release the claim", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.enqueue(body("c"), 0); + q.claim(1000, 10); + + q.advance("a", "ingested", 1000); + expect(q.statusOf("a")?.claimed_at).toBeNull(); + + q.markRetry("b", "503", 5000); + expect(q.statusOf("b")?.claimed_at).toBeNull(); + + q.markTerminal("c", "fatal", "401"); + expect(q.statusOf("c")?.claimed_at).toBeNull(); + q.close(); +}); + +test("after advance the next claim picks the record up at its new stage", () => { + const { q } = freshDb(); + q.enqueue(body("a"), 0); + q.claim(1000, 10); + q.advance("a", "ingested", 1000); + const claimed = q.claim(2000, 10); + expect(claimed).toHaveLength(1); + expect(claimed[0]!.state).toBe("ingested"); + q.close(); +}); + +test("claim respects limit + ordering, claims oldest first", () => { + const { q } = freshDb(); + q.enqueue(body("c"), 30); + q.enqueue(body("a"), 10); + q.enqueue(body("b"), 20); + const first = q.claim(1000, 2); + expect(first.map((r) => r.body.record_id)).toEqual(["a", "b"]); + // c is still unclaimed + expect(q.statusOf("a")?.claimed_at).toBe(1000); + expect(q.statusOf("c")?.claimed_at).toBeNull(); + q.close(); +}); diff --git a/llm-client/src/outbox/queue.ts b/llm-client/src/outbox/queue.ts new file mode 100644 index 0000000..4743a97 --- /dev/null +++ b/llm-client/src/outbox/queue.ts @@ -0,0 +1,325 @@ +import { Database } from "bun:sqlite"; +import type { IngestBody } from "./ingest-client"; + +/** + * Per-record lifecycle. + * + * captured -> ingested -> synced -> done + * + * At each non-terminal state the worker performs ONE action: + * captured : POST /ingest/context + * ingested : WebDAV PUT blob to Tbox + * synced : POST /ingest/context/{id}/uploaded + * + * Terminal failures: + * conflict : ingest got 409 (body mismatch) + * fatal : non-retryable 4xx (auth, schema, etc.) + */ +export type RecordState = + | "captured" + | "ingested" + | "synced" + | "done" + | "conflict" + | "fatal"; + +export const ACTIVE_STATES: RecordState[] = ["captured", "ingested", "synced"]; + +export type QueueStatus = { + record_id: string; + state: RecordState; + attempts: number; + next_attempt_at: number; + last_error: string | null; + claimed_at: number | null; +}; + +export type ReadyRecord = { + body: IngestBody; + state: RecordState; +}; + +const SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS outbox ( + record_id TEXT PRIMARY KEY, + session_id TEXT, + body_json TEXT NOT NULL, + state TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL, + last_error TEXT, + created_at INTEGER NOT NULL, + cache_evicted INTEGER NOT NULL DEFAULT 0, + claimed_at INTEGER + )`, + `CREATE INDEX IF NOT EXISTS ix_outbox_active + ON outbox (state, next_attempt_at)`, + `CREATE INDEX IF NOT EXISTS ix_outbox_evictable + ON outbox (state, cache_evicted, created_at)`, + // NOTE: ix_outbox_session is intentionally NOT here — it references + // session_id, which may not exist on a legacy DB. The migrateSchema() + // step creates the column (idempotently) and then the index. +]; + +export class IngestQueue { + private db: Database; + + constructor(path: string) { + this.db = new Database(path); + this.db.run("PRAGMA journal_mode = WAL"); + // Block up to 5s waiting on another writer instead of failing with SQLITE_BUSY. + // Matters when multiple router processes share one outbox file. + this.db.run("PRAGMA busy_timeout = 5000"); + for (const sql of SCHEMA_STATEMENTS) this.db.run(sql); + // Migration step must run AFTER the base schema exists but BEFORE + // any DDL that depends on added columns (e.g. ix_outbox_session). + this.migrateSchema(); + } + + /** + * Idempotently add columns + indexes missing on older databases. + * ALTER TABLE ADD COLUMN throws on duplicate, so we probe first via PRAGMA. + */ + private migrateSchema(): void { + const cols = this.db.prepare("PRAGMA table_info(outbox)").all() as Array<{ + name: string; + }>; + const has = (n: string) => cols.some((c) => c.name === n); + if (!has("session_id")) { + this.db.run("ALTER TABLE outbox ADD COLUMN session_id TEXT"); + } + // CREATE INDEX IF NOT EXISTS is safe on both legacy and fresh DBs + // — by this point session_id is guaranteed to exist. + this.db.run( + "CREATE INDEX IF NOT EXISTS ix_outbox_session ON outbox (session_id)", + ); + } + + enqueue(body: IngestBody, nextAttemptAt: number): void { + this.db + .prepare( + `INSERT OR IGNORE INTO outbox + (record_id, session_id, body_json, state, attempts, next_attempt_at, created_at) + VALUES (?, ?, ?, 'captured', 0, ?, ?)`, + ) + .run( + body.record_id, + body.session_id, + JSON.stringify(body), + nextAttemptAt, + Date.now(), + ); + } + + pickReady(now: number, limit: number): ReadyRecord[] { + const placeholders = ACTIVE_STATES.map(() => "?").join(","); + const rows = this.db + .prepare( + `SELECT body_json, state FROM outbox + WHERE state IN (${placeholders}) AND next_attempt_at <= ? + ORDER BY next_attempt_at ASC, created_at ASC + LIMIT ?`, + ) + .all(...ACTIVE_STATES, now, limit) as Array<{ + body_json: string; + state: RecordState; + }>; + return rows.map((r) => ({ + body: JSON.parse(r.body_json) as IngestBody, + state: r.state, + })); + } + + /** + * Atomically claim up to `limit` ready records and mark them as + * `claimed_at = now`. Returns the claimed batch. + * + * A row is "ready" when: + * - state is one of ACTIVE_STATES + * - next_attempt_at <= now + * - claimed_at is null OR older than `staleMs` (worker holding the + * claim is presumed dead) + * + * Implementation is a single UPDATE ... RETURNING so two concurrent + * workers (across processes on the same sqlite file) never see the + * same record. + */ + claim(now: number, limit: number, staleMs = 60_000): ReadyRecord[] { + const placeholders = ACTIVE_STATES.map(() => "?").join(","); + const rows = this.db + .prepare( + // The ORDER BY in the subquery influences which rows the UPDATE + // grabs, even though SQLite doesn't otherwise honour ORDER inside + // an IN clause. Kept for fairness across processes. + `UPDATE outbox + SET claimed_at = ? + WHERE record_id IN ( + SELECT record_id FROM outbox + WHERE state IN (${placeholders}) + AND next_attempt_at <= ? + AND (claimed_at IS NULL OR claimed_at < ?) + ORDER BY next_attempt_at ASC, created_at ASC + LIMIT ? + ) + RETURNING body_json, state`, + ) + .all(now, ...ACTIVE_STATES, now, now - staleMs, limit) as Array<{ + body_json: string; + state: RecordState; + }>; + return rows.map((r) => ({ + body: JSON.parse(r.body_json) as IngestBody, + state: r.state, + })); + } + + /** + * Advance to the next state on success. Resets attempts + clears error. + */ + advance( + record_id: string, + newState: RecordState, + nextAttemptAt: number, + ): void { + this.db + .prepare( + `UPDATE outbox + SET state = ?, attempts = 0, next_attempt_at = ?, + last_error = NULL, claimed_at = NULL + WHERE record_id = ?`, + ) + .run(newState, nextAttemptAt, record_id); + } + + /** + * Retry the current stage. Increments attempts, sets next_attempt_at, + * records error. State unchanged. Claim is released so another worker + * can pick the record up on its next tick. + */ + markRetry(record_id: string, error: string, nextAttemptAt: number): void { + this.db + .prepare( + `UPDATE outbox + SET attempts = attempts + 1, + next_attempt_at = ?, + last_error = ?, + claimed_at = NULL + WHERE record_id = ?`, + ) + .run(nextAttemptAt, error, record_id); + } + + /** Mark a terminal failure (conflict | fatal). No further work attempted. */ + markTerminal( + record_id: string, + finalState: "conflict" | "fatal", + error: string, + ): void { + this.db + .prepare( + `UPDATE outbox + SET state = ?, last_error = ?, claimed_at = NULL + WHERE record_id = ?`, + ) + .run(finalState, error, record_id); + } + + statusOf(record_id: string): QueueStatus | undefined { + const row = this.db + .prepare( + `SELECT record_id, state, attempts, next_attempt_at, last_error, claimed_at + FROM outbox WHERE record_id = ?`, + ) + .get(record_id) as QueueStatus | null; + return row ?? undefined; + } + + /** + * Return sessions whose on-disk cache file is safe to delete. A session + * is evictable when: + * + * - no record in the session is captured or ingested (no turn still + * needs to read and upload the file) + * - at least one record still has cache_evicted=0 (otherwise this + * session has already been processed) + * - MAX(created_at) across the session's records is older than the + * threshold (the session is "settled") + * + * Capped by `limit` SESSIONS (not records). Oldest sessions first. + */ + findEvictableSessions( + olderThanCreatedAt: number, + limit: number, + ): Array<{ session_id: string; record_ids: string[] }> { + // Separator = ASCII unit separator (0x1F). Can't appear in a UUID + // record_id, so the split back is safe even if the id format changes. + const SEP = "\x1f"; + const rows = this.db + .prepare( + `SELECT session_id, group_concat(record_id, '${SEP}') AS record_ids + FROM outbox + WHERE session_id IS NOT NULL + GROUP BY session_id + HAVING SUM(CASE WHEN state IN ('captured', 'ingested') THEN 1 ELSE 0 END) = 0 + AND SUM(CASE WHEN cache_evicted = 0 THEN 1 ELSE 0 END) > 0 + AND MAX(created_at) < ? + ORDER BY MAX(created_at) ASC + LIMIT ?`, + ) + .all(olderThanCreatedAt, limit) as Array<{ + session_id: string; + record_ids: string; + }>; + return rows.map((r) => ({ + session_id: r.session_id, + record_ids: r.record_ids.split(SEP), + })); + } + + /** + * Return one session whose cache file no longer needs to stay local. + * + * `captured` and `ingested` records still need `records/.json` + * because the sync stage may read and upload it. Once every record in + * the session has moved to `synced` or any terminal state, jbox already + * has the bytes needed for future backend verification. + */ + findReclaimableSession( + session_id: string, + ): { session_id: string; record_ids: string[] } | undefined { + const SEP = "\x1f"; + const row = this.db + .prepare( + `SELECT session_id, group_concat(record_id, '${SEP}') AS record_ids + FROM outbox + WHERE session_id = ? + GROUP BY session_id + HAVING SUM(CASE WHEN state IN ('captured', 'ingested') THEN 1 ELSE 0 END) = 0 + AND SUM(CASE WHEN cache_evicted = 0 THEN 1 ELSE 0 END) > 0`, + ) + .get(session_id) as { session_id: string; record_ids: string } | null; + if (row === null) return undefined; + return { + session_id: row.session_id, + record_ids: row.record_ids.split(SEP), + }; + } + + markEvicted(record_id: string): void { + this.db + .prepare(`UPDATE outbox SET cache_evicted = 1 WHERE record_id = ?`) + .run(record_id); + } + + /** Check whether the cache file for this record has been freed. */ + isEvicted(record_id: string): boolean { + const row = this.db + .prepare(`SELECT cache_evicted FROM outbox WHERE record_id = ?`) + .get(record_id) as { cache_evicted: number } | null; + return row?.cache_evicted === 1; + } + + close(): void { + this.db.close(); + } +} diff --git a/llm-client/src/outbox/relay.test.ts b/llm-client/src/outbox/relay.test.ts new file mode 100644 index 0000000..1c2ef2b --- /dev/null +++ b/llm-client/src/outbox/relay.test.ts @@ -0,0 +1,263 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { IngestQueue } from "./queue"; +import type { IngestBody } from "./ingest-client"; +import { + nextBackoff, + DEFAULT_BACKOFF, + runOnce, + type Stages, + type StageHandler, +} from "./relay"; + +const tmpDirs: string[] = []; +function freshQueue() { + const d = mkdtempSync(join(tmpdir(), "aimdware-worker-")); + tmpDirs.push(d); + return new IngestQueue(join(d, "queue.db")); +} +function body(id: string): IngestBody { + return { + record_id: id, + session_id: `sess-${id}`, + turn_count: 1, + course_code: "ECE4721J", + assignment: "hw1", + blob_hash: "h", + blob_uri: `jbox://x/${id}.json`, + blob_size: 1, + ts: "2026-05-11T00:00:00.000Z", + router_version: "0.0.0", + }; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +const advance: StageHandler = async () => ({ kind: "advance" }); +const stub3 = (h: StageHandler): Stages => ({ ingest: h, sync: h, confirm: h }); + +test("nextBackoff schedule: 1s, 5s, 30s, 5m, 30m, 1h cap", () => { + expect(nextBackoff(0)).toBe(1_000); + expect(nextBackoff(1)).toBe(5_000); + expect(nextBackoff(2)).toBe(30_000); + expect(nextBackoff(5)).toBe(60 * 60_000); + expect(nextBackoff(99)).toBe(60 * 60_000); + expect(DEFAULT_BACKOFF.length).toBe(6); +}); + +test("captured + ingest.advance -> state=ingested", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); + expect(q.statusOf("r1")?.state).toBe("ingested"); + q.close(); +}); + +test("ingested + sync.advance -> state=synced", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + q.advance("r1", "ingested", 0); + await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); + expect(q.statusOf("r1")?.state).toBe("synced"); + q.close(); +}); + +test("sync.advance fires afterAdvance after the row reaches synced", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + q.advance("r1", "ingested", 0); + const seen: Array<{ from: string; to: string; state: string | undefined }> = + []; + await runOnce({ + queue: q, + stages: stub3(advance), + now: () => 100, + afterAdvance: async (b, from, to) => { + seen.push({ from, to, state: q.statusOf(b.record_id)?.state }); + }, + }); + + expect(seen).toEqual([{ from: "ingested", to: "synced", state: "synced" }]); + q.close(); +}); + +test("synced + confirm.advance -> state=done", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + q.advance("r1", "synced", 0); + await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); + expect(q.statusOf("r1")?.state).toBe("done"); + q.close(); +}); + +test("retry result: attempts++ and next_attempt_at scheduled by backoff", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const retry: StageHandler = async () => ({ kind: "retry", reason: "503" }); + await runOnce({ queue: q, stages: stub3(retry), now: () => 1000 }); + let s = q.statusOf("r1")!; + expect(s.state).toBe("captured"); + expect(s.attempts).toBe(1); + expect(s.next_attempt_at).toBe(1000 + 1_000); + + await runOnce({ queue: q, stages: stub3(retry), now: () => 5000 }); + s = q.statusOf("r1")!; + expect(s.attempts).toBe(2); + expect(s.next_attempt_at).toBe(5000 + 5_000); + q.close(); +}); + +test("terminal -> markTerminal, no retry", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fatal: StageHandler = async () => ({ + kind: "terminal", + finalState: "fatal", + reason: "401", + }); + await runOnce({ queue: q, stages: stub3(fatal), now: () => 1000 }); + expect(q.statusOf("r1")?.state).toBe("fatal"); + expect(q.statusOf("r1")?.last_error).toContain("401"); + q.close(); +}); + +test("handler-throws is treated as retryable", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const boom: StageHandler = async () => { + throw new Error("kaboom"); + }; + await runOnce({ queue: q, stages: stub3(boom), now: () => 0 }); + const s = q.statusOf("r1")!; + expect(s.state).toBe("captured"); + expect(s.attempts).toBe(1); + expect(s.last_error).toContain("kaboom"); + q.close(); +}); + +test("nothing ready -> processed=0", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 5000); + const summary = await runOnce({ + queue: q, + stages: stub3(advance), + now: () => 100, + }); + expect(summary.processed).toBe(0); + q.close(); +}); + +test("dispatches the right handler per record state", async () => { + const q = freshQueue(); + q.enqueue(body("a"), 0); // captured -> ingest + q.enqueue(body("b"), 0); + q.advance("b", "ingested", 0); // ingested -> sync + q.enqueue(body("c"), 0); + q.advance("c", "ingested", 0); + q.advance("c", "synced", 0); // synced -> confirm + + const called: string[] = []; + const stages: Stages = { + ingest: async (b) => { + called.push(`ingest:${b.record_id}`); + return { kind: "advance" }; + }, + sync: async (b) => { + called.push(`sync:${b.record_id}`); + return { kind: "advance" }; + }, + confirm: async (b) => { + called.push(`confirm:${b.record_id}`); + return { kind: "advance" }; + }, + }; + await runOnce({ queue: q, stages, now: () => 100 }); + + expect(called.sort()).toEqual(["confirm:c", "ingest:a", "sync:b"]); + expect(q.statusOf("a")?.state).toBe("ingested"); + expect(q.statusOf("b")?.state).toBe("synced"); + expect(q.statusOf("c")?.state).toBe("done"); + q.close(); +}); + +test("processes the batch in parallel up to concurrency", async () => { + const q = freshQueue(); + for (let i = 0; i < 8; i++) q.enqueue(body(`r${i}`), 0); + + let inFlight = 0; + let peak = 0; + const slow: StageHandler = async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await Bun.sleep(20); + inFlight -= 1; + return { kind: "advance" }; + }; + + await runOnce( + { queue: q, stages: stub3(slow), now: () => 0, concurrency: 4 }, + 8, + ); + + expect(peak).toBeGreaterThanOrEqual(2); // actually parallel (not serial) + expect(peak).toBeLessThanOrEqual(4); // bounded by concurrency + for (let i = 0; i < 8; i++) { + expect(q.statusOf(`r${i}`)?.state).toBe("ingested"); + } + q.close(); +}); + +test("multi-turn session: independent records sharing one session_id both advance through stages", async () => { + // A realistic agent flow: three turns of the same session show up as + // three records in the outbox, sharing one session_id. Each turn must + // advance through its own stages independently of the others. + const q = freshQueue(); + function turn(record_id: string, turn_count: number): IngestBody { + return { ...body(record_id), session_id: "S-agent", turn_count }; + } + q.enqueue(turn("t1", 1), 0); + q.enqueue(turn("t2", 2), 0); + q.enqueue(turn("t3", 3), 0); + // Pre-advance t2 to "ingested" so it should hit `sync`, t3 to "synced" + // for `confirm` — exercises the dispatcher with shared-session data. + q.advance("t2", "ingested", 0); + q.advance("t3", "ingested", 0); + q.advance("t3", "synced", 0); + + const seen: Record = { ingest: [], sync: [], confirm: [] }; + const stages: Stages = { + ingest: async (b) => { + seen.ingest!.push(b.record_id); + return { kind: "advance" }; + }, + sync: async (b) => { + seen.sync!.push(b.record_id); + return { kind: "advance" }; + }, + confirm: async (b) => { + seen.confirm!.push(b.record_id); + return { kind: "advance" }; + }, + }; + await runOnce({ queue: q, stages, now: () => 100 }); + + expect(seen.ingest).toEqual(["t1"]); + expect(seen.sync).toEqual(["t2"]); + expect(seen.confirm).toEqual(["t3"]); + + // Round-trip check: body_json stored in the outbox must carry the + // shared session_id and the per-turn turn_count. Use a fresh queue so + // we can pickReady (the records above have advanced past 'ready'). + const q2 = freshQueue(); + q2.enqueue(turn("a", 7), 0); + q2.enqueue(turn("b", 8), 0); + const bodies = q2.pickReady(2000, 10).map((r) => r.body); + expect(bodies.every((b) => b.session_id === "S-agent")).toBe(true); + expect(bodies.map((b) => b.turn_count).sort()).toEqual([7, 8]); + q.close(); + q2.close(); +}); diff --git a/llm-client/src/outbox/relay.ts b/llm-client/src/outbox/relay.ts new file mode 100644 index 0000000..9c5cce3 --- /dev/null +++ b/llm-client/src/outbox/relay.ts @@ -0,0 +1,207 @@ +import type { IngestQueue, RecordState } from "./queue"; +import type { IngestBody } from "./ingest-client"; +import { StoppableSleep } from "../util"; + +export const DEFAULT_BACKOFF: number[] = [ + 1_000, + 5_000, + 30_000, + 5 * 60_000, + 30 * 60_000, + 60 * 60_000, +]; + +export function nextBackoff( + attempts: number, + schedule: number[] = DEFAULT_BACKOFF, +): number { + const i = Math.min(attempts, schedule.length - 1); + return schedule[i] ?? schedule[schedule.length - 1]!; +} + +/** + * Uniform return shape from each stage handler. The worker translates this + * into a queue transition, so handlers don't need to know about queue state. + */ +export type StageResult = + | { kind: "advance" } + | { kind: "retry"; reason: string } + | { kind: "terminal"; finalState: "conflict" | "fatal"; reason: string }; + +export type StageHandler = (body: IngestBody) => Promise; + +export type Stages = { + ingest: StageHandler; // called on state=captured + sync: StageHandler; // called on state=ingested + confirm: StageHandler; // called on state=synced +}; + +export type WorkerOpts = { + queue: IngestQueue; + stages: Stages; + now?: () => number; + backoff?: number[]; + concurrency?: number; + afterAdvance?: ( + body: IngestBody, + from: RecordState, + to: RecordState, + ) => void | Promise; +}; + +export type RunSummary = { + processed: number; + advance: number; + retry: number; + terminal: number; +}; + +function stageFor( + state: RecordState, + stages: Stages, +): StageHandler | undefined { + switch (state) { + case "captured": + return stages.ingest; + case "ingested": + return stages.sync; + case "synced": + return stages.confirm; + default: + return undefined; + } +} + +function nextStateAfter(state: RecordState): RecordState { + switch (state) { + case "captured": + return "ingested"; + case "ingested": + return "synced"; + case "synced": + return "done"; + default: + return state; + } +} + +export async function runOnce( + opts: WorkerOpts, + limit = 50, +): Promise { + const now = (opts.now ?? Date.now)(); + const concurrency = opts.concurrency ?? 4; + const backoff = opts.backoff ?? DEFAULT_BACKOFF; + + const ready = opts.queue.claim(now, limit); + + const summary: RunSummary = { + processed: ready.length, + advance: 0, + retry: 0, + terminal: 0, + }; + + // Bound concurrency. Records inside the batch run in parallel up to `concurrency`. + let cursor = 0; + const workers = Array.from( + { length: Math.min(concurrency, ready.length) }, + async () => { + while (true) { + const i = cursor++; + if (i >= ready.length) return; + const slot = ready[i]!; + const handler = stageFor(slot.state, opts.stages); + if (!handler) continue; + let result: StageResult; + try { + result = await handler(slot.body); + } catch (e) { + result = { + kind: "retry", + reason: (e as Error).message ?? "handler threw", + }; + } + const advancedTo = applyResult( + opts.queue, + slot.body.record_id, + slot.state, + result, + now, + backoff, + ); + if (advancedTo !== undefined) { + try { + await opts.afterAdvance?.(slot.body, slot.state, advancedTo); + } catch (e) { + console.error("afterAdvance failed:", (e as Error).message); + } + } + if (result.kind === "advance") summary.advance += 1; + else if (result.kind === "retry") summary.retry += 1; + else summary.terminal += 1; + } + }, + ); + await Promise.all(workers); + + return summary; +} + +function applyResult( + queue: IngestQueue, + recordId: string, + currentState: RecordState, + result: StageResult, + now: number, + backoff: number[], +): RecordState | undefined { + switch (result.kind) { + case "advance": { + const nextState = nextStateAfter(currentState); + queue.advance(recordId, nextState, now); + return nextState; + } + case "retry": { + const attempts = queue.statusOf(recordId)?.attempts ?? 0; + queue.markRetry( + recordId, + result.reason, + now + nextBackoff(attempts, backoff), + ); + return undefined; + } + case "terminal": { + queue.markTerminal(recordId, result.finalState, result.reason); + return undefined; + } + } +} + +export type WorkerLoopHandle = { stop: () => Promise }; + +export function startWorkerLoop( + opts: WorkerOpts, + pollMs = 1000, +): WorkerLoopHandle { + const sleeper = new StoppableSleep(); + let stopped = false; + const done = (async () => { + while (!stopped) { + try { + await runOnce(opts); + } catch (e) { + console.error("worker tick failed:", (e as Error).message); + } + if (stopped) break; + await sleeper.sleep(pollMs); + } + })(); + return { + async stop() { + stopped = true; + sleeper.stop(); + await done; + }, + }; +} diff --git a/llm-client/src/outbox/sync.real.test.ts b/llm-client/src/outbox/sync.real.test.ts new file mode 100644 index 0000000..b4258d4 --- /dev/null +++ b/llm-client/src/outbox/sync.real.test.ts @@ -0,0 +1,103 @@ +/** + * Real Tbox integration test. Skipped automatically when no Tbox is + * reachable. Override via env vars: + * AIMDWARE_TBOX_URL (default http://127.0.0.1:50471) + * AIMDWARE_TBOX_USER (default admin) + * AIMDWARE_TBOX_PASS (default admin) + */ +import { test, expect } from "bun:test"; +import { createClient } from "webdav"; +import { makeWebDAVPut, syncBlob } from "./sync"; + +const TBOX_URL = process.env.AIMDWARE_TBOX_URL ?? "http://127.0.0.1:50471"; +const TBOX_USER = process.env.AIMDWARE_TBOX_USER ?? "admin"; +const TBOX_PASS = process.env.AIMDWARE_TBOX_PASS ?? "admin"; + +async function reachable(): Promise { + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 1500); + await fetch(TBOX_URL, { signal: ctrl.signal }); + clearTimeout(t); + return true; + } catch { + return false; + } +} + +test("real Tbox: PUT (with auto-MKCOL) then GET roundtrip", async () => { + if (!(await reachable())) { + console.log(`[skip] Tbox not reachable at ${TBOX_URL}`); + return; + } + const auth = { username: TBOX_USER, password: TBOX_PASS }; + const put = makeWebDAVPut(TBOX_URL, auth); + + const subdir = `aimdware-it-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const path = `/${subdir}/sample.json`; + const payload = new TextEncoder().encode( + `{"hello":"tbox","ts":${Date.now()}}`, + ); + + const result = await syncBlob(put, path, payload); + expect(result).toEqual({ kind: "synced" }); + + const client = createClient(TBOX_URL, auth); + const got = (await client.getFileContents(path)) as Buffer; + expect(Buffer.from(got).equals(Buffer.from(payload))).toBe(true); + + // Cleanup (best effort). + try { + await client.deleteFile(`/${subdir}`); + } catch { + /* ignore */ + } +}); + +test("real Tbox: second PUT to the same parent reuses cached MKCOL (no extra MKCOL roundtrip)", async () => { + if (!(await reachable())) { + console.log(`[skip] Tbox not reachable at ${TBOX_URL}`); + return; + } + const auth = { username: TBOX_USER, password: TBOX_PASS }; + const put = makeWebDAVPut(TBOX_URL, auth); + + const subdir = `aimdware-it-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const pathA = `/${subdir}/a.json`; + const pathB = `/${subdir}/b.json`; + + expect(await syncBlob(put, pathA, new TextEncoder().encode("A"))).toEqual({ + kind: "synced", + }); + expect(await syncBlob(put, pathB, new TextEncoder().encode("B"))).toEqual({ + kind: "synced", + }); + + const client = createClient(TBOX_URL, auth); + expect( + Buffer.from((await client.getFileContents(pathA)) as Buffer).toString(), + ).toBe("A"); + expect( + Buffer.from((await client.getFileContents(pathB)) as Buffer).toString(), + ).toBe("B"); + + try { + await client.deleteFile(`/${subdir}`); + } catch { + /* ignore */ + } +}); + +test("real Tbox: wrong password yields a fatal (4xx) sync result", async () => { + if (!(await reachable())) { + console.log(`[skip] Tbox not reachable at ${TBOX_URL}`); + return; + } + const put = makeWebDAVPut(TBOX_URL, { + username: "admin", + password: "definitely-wrong", + }); + const path = `/aimdware-it-${Date.now()}/x.json`; + const r = await syncBlob(put, path, new TextEncoder().encode("x")); + expect(r.kind).toBe("fatal"); +}); diff --git a/llm-client/src/outbox/sync.test.ts b/llm-client/src/outbox/sync.test.ts new file mode 100644 index 0000000..4789bb8 --- /dev/null +++ b/llm-client/src/outbox/sync.test.ts @@ -0,0 +1,77 @@ +import { test, expect } from "bun:test"; +import { syncBlob, type WebDAVPutLike, type SyncResult } from "./sync"; + +const ok: WebDAVPutLike = async () => {}; + +test("syncBlob success -> synced", async () => { + let putPath = ""; + let putBytes: Uint8Array = new Uint8Array(); + const put: WebDAVPutLike = async (path, data) => { + putPath = path; + putBytes = data; + }; + + const result = await syncBlob( + put, + "/aimdware/ECE4721J/abc.json", + new TextEncoder().encode("payload"), + ); + + expect(result).toEqual({ kind: "synced" }); + expect(putPath).toBe("/aimdware/ECE4721J/abc.json"); + expect(new TextDecoder().decode(putBytes)).toBe("payload"); +}); + +test("syncBlob network error -> retryable", async () => { + const put: WebDAVPutLike = async () => { + throw new TypeError("Connection refused"); + }; + const result = await syncBlob(put, "/x", new Uint8Array()); + expect(result.kind).toBe("retryable"); +}); + +test("syncBlob WebDAV 5xx -> retryable", async () => { + const put: WebDAVPutLike = async () => { + const e: Error & { status?: number } = new Error("server error"); + e.status = 503; + throw e; + }; + const result = await syncBlob(put, "/x", new Uint8Array()); + expect(result.kind).toBe("retryable"); + if (result.kind === "retryable") expect(result.reason).toContain("503"); +}); + +test("syncBlob WebDAV 401 -> fatal", async () => { + const put: WebDAVPutLike = async () => { + const e: Error & { status?: number } = new Error("unauthorized"); + e.status = 401; + throw e; + }; + const result = await syncBlob(put, "/x", new Uint8Array()); + expect(result.kind).toBe("fatal"); +}); + +test("syncBlob WebDAV 409 -> fatal (likely a path/server config bug)", async () => { + const put: WebDAVPutLike = async () => { + const e: Error & { status?: number } = new Error("conflict"); + e.status = 409; + throw e; + }; + const result = await syncBlob(put, "/x", new Uint8Array()); + expect(result.kind).toBe("fatal"); +}); + +test("syncBlob 429 rate-limited -> retryable", async () => { + const put: WebDAVPutLike = async () => { + const e: Error & { status?: number } = new Error("too many requests"); + e.status = 429; + throw e; + }; + const result = await syncBlob(put, "/x", new Uint8Array()); + expect(result.kind).toBe("retryable"); +}); + +test("syncBlob with explicit ok handler reports a synced result", async () => { + const r = await syncBlob(ok, "/p", new Uint8Array()); + expect(r.kind).toBe("synced"); +}); diff --git a/llm-client/src/outbox/sync.ts b/llm-client/src/outbox/sync.ts new file mode 100644 index 0000000..642ded6 --- /dev/null +++ b/llm-client/src/outbox/sync.ts @@ -0,0 +1,69 @@ +import { createClient, type WebDAVClient } from "webdav"; + +/** + * Just enough surface for our PUTs. Lets tests inject a fake without + * spinning up a real WebDAV server. + */ +export type WebDAVPutLike = (path: string, data: Uint8Array) => Promise; + +export type SyncResult = + | { kind: "synced" } + | { kind: "retryable"; reason: string } + | { kind: "fatal"; reason: string }; + +export async function syncBlob( + put: WebDAVPutLike, + remotePath: string, + data: Uint8Array, +): Promise { + try { + await put(remotePath, data); + return { kind: "synced" }; + } catch (e) { + const err = e as Error & { + status?: number; + response?: { status?: number }; + }; + const status = err.status ?? err.response?.status; + if (status === undefined) { + // Network / DNS / connection refused / abort. + return { kind: "retryable", reason: err.message ?? "unknown" }; + } + if (status >= 500 || status === 429 || status === 408) { + return { kind: "retryable", reason: `webdav ${status}` }; + } + // Other 4xx — auth, malformed path, conflict, locked, etc. — non-retryable. + return { kind: "fatal", reason: `webdav ${status}: ${err.message ?? ""}` }; + } +} + +export type WebDAVAuth = { username: string; password: string }; + +/** + * Build a webdav-package-backed PUT function bound to a Tbox URL. + * + * Lazily MKCOLs parent directories the first time a blob targets them. + * Tbox returns 409 on PUT into a missing parent, so without this every + * sync would fail until something else created the course folder. + */ +export function makeWebDAVPut( + tboxUrl: string, + auth?: WebDAVAuth, +): WebDAVPutLike { + const client: WebDAVClient = createClient( + tboxUrl, + auth ? { username: auth.username, password: auth.password } : undefined, + ); + const ensuredDirs = new Set(); + return async (path, data) => { + const slash = path.lastIndexOf("/"); + const parent = slash > 0 ? path.slice(0, slash) : ""; + if (parent && !ensuredDirs.has(parent)) { + await client.createDirectory(parent, { recursive: true }); + ensuredDirs.add(parent); + } + const buf = Buffer.from(data); + const ok = await client.putFileContents(path, buf, { overwrite: true }); + if (!ok) throw new Error("webdav putFileContents returned false"); + }; +} diff --git a/llm-client/src/providers/auth-login.test.ts b/llm-client/src/providers/auth-login.test.ts new file mode 100644 index 0000000..4e5147c --- /dev/null +++ b/llm-client/src/providers/auth-login.test.ts @@ -0,0 +1,314 @@ +import { test, expect } from "bun:test"; +import { loginCodexDevice, loginCopilotDevice } from "./auth-login"; +import type { AuthStore, ProviderAuth } from "./auth-store"; +import type { FetchLike } from "../http/proxy"; + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +] as const; + +function snapshotProxyEnv(): Partial< + Record<(typeof PROXY_ENV_KEYS)[number], string> +> { + const snapshot: Partial> = {}; + for (const key of PROXY_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) snapshot[key] = value; + delete process.env[key]; + } + return snapshot; +} + +function restoreProxyEnv( + snapshot: Partial>, +): void { + for (const key of PROXY_ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function memoryStore(): AuthStore & { values: Map } { + const values = new Map(); + return { + values, + async get(id) { + return values.get(id); + }, + async set(id, auth) { + values.set(id, auth); + }, + async del(id) { + values.delete(id); + }, + }; +} + +test("loginCodexDevice stores refreshed oauth credentials", async () => { + const store = memoryStore(); + const prompts: string[] = []; + const calls: string[] = []; + const fetchImpl: FetchLike = async (input) => { + const url = String(input); + calls.push(url); + if (url.endsWith("/api/accounts/deviceauth/usercode")) { + return Response.json({ + device_auth_id: "device-id", + user_code: "ABCD-EFGH", + interval: "1", + }); + } + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ + authorization_code: "auth-code", + code_verifier: "verifier", + }); + } + return Response.json({ + access_token: "codex-access", + refresh_token: "codex-refresh", + expires_in: 3600, + }); + }; + + await loginCodexDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + now: () => 10_000, + notify: (line) => prompts.push(line), + }); + + expect(calls).toEqual([ + "https://auth.openai.com/api/accounts/deviceauth/usercode", + "https://auth.openai.com/api/accounts/deviceauth/token", + "https://auth.openai.com/oauth/token", + ]); + expect(prompts.join("\n")).toContain("ABCD-EFGH"); + expect(store.values.get("codex")).toMatchObject({ + type: "oauth", + access: "codex-access", + refresh: "codex-refresh", + expires: 3_610_000, + }); +}); + +test("loginCodexDevice sends auth requests through HTTPS_PROXY", async () => { + const originalProxy = snapshotProxyEnv(); + process.env.HTTPS_PROXY = "http://127.0.0.1:10870"; + const store = memoryStore(); + const calls: Array = []; + const fetchImpl: FetchLike = async (input, init) => { + calls.push((init ?? {}) as RequestInit & { proxy?: string }); + const url = String(input); + if (url.endsWith("/api/accounts/deviceauth/usercode")) { + return Response.json({ + device_auth_id: "device-id", + user_code: "ABCD-EFGH", + interval: "1", + }); + } + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ + authorization_code: "auth-code", + code_verifier: "verifier", + }); + } + return Response.json({ + access_token: "codex-access", + refresh_token: "codex-refresh", + expires_in: 3600, + }); + }; + + try { + await loginCodexDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + notify: () => {}, + }); + } finally { + restoreProxyEnv(originalProxy); + } + + expect(calls.map((call) => call.proxy)).toEqual([ + "http://127.0.0.1:10870", + "http://127.0.0.1:10870", + "http://127.0.0.1:10870", + ]); +}); + +test("loginCodexDevice sends a User-Agent on the oauth token exchange", async () => { + const store = memoryStore(); + let tokenExchangeUA: string | null = null; + const fetchImpl: FetchLike = async (input, init) => { + const url = String(input); + if (url.endsWith("/api/accounts/deviceauth/usercode")) { + return Response.json({ + device_auth_id: "device-id", + user_code: "ABCD-EFGH", + interval: "1", + }); + } + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ + authorization_code: "auth-code", + code_verifier: "verifier", + }); + } + tokenExchangeUA = new Headers(init?.headers).get("user-agent"); + return Response.json({ + access_token: "codex-access", + refresh_token: "codex-refresh", + expires_in: 3600, + }); + }; + + await loginCodexDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + notify: () => {}, + }); + + expect(tokenExchangeUA).not.toBeNull(); +}); + +test("loginCodexDevice rejects a token response missing access_token", async () => { + const store = memoryStore(); + const fetchImpl: FetchLike = async (input) => { + const url = String(input); + if (url.endsWith("/api/accounts/deviceauth/usercode")) { + return Response.json({ + device_auth_id: "device-id", + user_code: "ABCD-EFGH", + interval: "1", + }); + } + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ + authorization_code: "auth-code", + code_verifier: "verifier", + }); + } + return Response.json({ refresh_token: "codex-refresh", expires_in: 3600 }); + }; + + await expect( + loginCodexDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + notify: () => {}, + }), + ).rejects.toThrow(/access_token/); + expect(store.values.get("codex")).toBeUndefined(); +}); + +test("loginCodexDevice gives up once the device code expires instead of polling forever", async () => { + const store = memoryStore(); + let clock = 0; + const fetchImpl: FetchLike = async (input) => { + const url = String(input); + if (url.endsWith("/api/accounts/deviceauth/usercode")) { + return Response.json({ + device_auth_id: "device-id", + user_code: "ABCD-EFGH", + interval: "1", + expires_in: 2, + }); + } + // token endpoint never authorizes (always pending) + return new Response("", { status: 403 }); + }; + + await expect( + loginCodexDevice({ + authStore: store, + fetchImpl, + sleep: async () => { + clock += 1000; + }, + now: () => clock, + notify: () => {}, + }), + ).rejects.toThrow(/auth login codex/); +}); + +test("loginCopilotDevice stores GitHub Copilot oauth credentials", async () => { + const store = memoryStore(); + const prompts: string[] = []; + const fetchImpl: FetchLike = async (input) => { + const url = String(input); + if (url.endsWith("/login/device/code")) { + return Response.json({ + verification_uri: "https://github.com/login/device", + user_code: "WXYZ-1234", + device_code: "device-code", + interval: 1, + }); + } + return Response.json({ access_token: "gho-token" }); + }; + + await loginCopilotDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + notify: (line) => prompts.push(line), + }); + + expect(prompts.join("\n")).toContain("https://github.com/login/device"); + expect(prompts.join("\n")).toContain("WXYZ-1234"); + expect(store.values.get("copilot")).toEqual({ + type: "oauth", + access: "gho-token", + refresh: "gho-token", + expires: 0, + }); +}); + +test("loginCopilotDevice sends auth requests through HTTPS_PROXY", async () => { + const originalProxy = snapshotProxyEnv(); + process.env.HTTPS_PROXY = "http://127.0.0.1:10870"; + const store = memoryStore(); + const calls: Array = []; + const fetchImpl: FetchLike = async (input, init) => { + calls.push((init ?? {}) as RequestInit & { proxy?: string }); + const url = String(input); + if (url.endsWith("/login/device/code")) { + return Response.json({ + verification_uri: "https://github.com/login/device", + user_code: "WXYZ-1234", + device_code: "device-code", + interval: 1, + }); + } + return Response.json({ access_token: "gho-token" }); + }; + + try { + await loginCopilotDevice({ + authStore: store, + fetchImpl, + sleep: async () => {}, + notify: () => {}, + }); + } finally { + restoreProxyEnv(originalProxy); + } + + expect(calls.map((call) => call.proxy)).toEqual([ + "http://127.0.0.1:10870", + "http://127.0.0.1:10870", + ]); +}); diff --git a/llm-client/src/providers/auth-login.ts b/llm-client/src/providers/auth-login.ts new file mode 100644 index 0000000..fe2ac8b --- /dev/null +++ b/llm-client/src/providers/auth-login.ts @@ -0,0 +1,216 @@ +import type { FetchLike } from "../http/proxy"; +import type { AuthStore, OAuthAuth } from "./auth-store"; +import { extractCodexAccountId, parseTokenResponse } from "./codex"; +import { fetchWithProxy, userAgent } from "./plugin"; + +const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const CODEX_ISSUER = "https://auth.openai.com"; +const COPILOT_CLIENT_ID = "Ov23li8tweQw6odWQebz"; +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000; + +type LoginOpts = { + authStore: AuthStore; + fetchImpl?: FetchLike; + sleep?: (ms: number) => Promise; + notify?: (line: string) => void; + now?: () => number; +}; + +function defaults(opts: LoginOpts): Required { + return { + fetchImpl: opts.fetchImpl ?? (fetch as unknown as FetchLike), + sleep: + opts.sleep ?? + ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), + notify: opts.notify ?? ((line) => console.log(line)), + now: opts.now ?? Date.now, + authStore: opts.authStore, + }; +} + +export async function loginCodexDevice(opts: LoginOpts): Promise { + const d = defaults(opts); + const deviceResponse = await fetchWithProxy( + d.fetchImpl, + `${CODEX_ISSUER}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": userAgent(), + }, + body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), + }, + ); + if (!deviceResponse.ok) { + throw new Error( + `Codex device authorization failed: ${deviceResponse.status}`, + ); + } + + const deviceData = (await deviceResponse.json()) as { + device_auth_id: string; + user_code: string; + interval: string; + expires_in?: number | string; + }; + const interval = Math.max(Number.parseInt(deviceData.interval) || 5, 1); + const ttlSeconds = Number(deviceData.expires_in); + const deadlineMs = + (Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 900) * 1000; + const startedAt = d.now(); + d.notify(`Open ${CODEX_ISSUER}/codex/device`); + d.notify(`Enter code: ${deviceData.user_code}`); + + while (true) { + if (d.now() - startedAt >= deadlineMs) { + throw new Error( + "Codex device authorization expired before it was approved. " + + "Re-run `aimdware-router auth login codex`.", + ); + } + const response = await fetchWithProxy( + d.fetchImpl, + `${CODEX_ISSUER}/api/accounts/deviceauth/token`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": userAgent(), + }, + body: JSON.stringify({ + device_auth_id: deviceData.device_auth_id, + user_code: deviceData.user_code, + }), + }, + ); + + if (response.ok) { + const code = (await response.json()) as { + authorization_code: string; + code_verifier: string; + }; + const tokenResponse = await fetchWithProxy( + d.fetchImpl, + `${CODEX_ISSUER}/oauth/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": userAgent(), + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code.authorization_code, + redirect_uri: `${CODEX_ISSUER}/deviceauth/callback`, + client_id: CODEX_CLIENT_ID, + code_verifier: code.code_verifier, + }).toString(), + }, + ); + if (!tokenResponse.ok) { + throw new Error(`Codex token exchange failed: ${tokenResponse.status}`); + } + const tokens = parseTokenResponse(await tokenResponse.json()); + const auth: OAuthAuth = { + type: "oauth", + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: d.now() + (tokens.expires_in ?? 3600) * 1000, + account_id: extractCodexAccountId(tokens), + }; + await d.authStore.set("codex", auth); + return auth; + } + + if (response.status !== 403 && response.status !== 404) { + throw new Error(`Codex authorization polling failed: ${response.status}`); + } + await d.sleep(interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS); + } +} + +export async function loginCopilotDevice( + opts: LoginOpts & { enterpriseUrl?: string }, +): Promise { + const d = defaults(opts); + const domain = opts.enterpriseUrl + ? opts.enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") + : "github.com"; + const deviceUrl = `https://${domain}/login/device/code`; + const tokenUrl = `https://${domain}/login/oauth/access_token`; + const deviceResponse = await fetchWithProxy(d.fetchImpl, deviceUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": userAgent(), + }, + body: JSON.stringify({ + client_id: COPILOT_CLIENT_ID, + scope: "read:user", + }), + }); + if (!deviceResponse.ok) { + throw new Error( + `GitHub Copilot device authorization failed: ${deviceResponse.status}`, + ); + } + + const deviceData = (await deviceResponse.json()) as { + verification_uri: string; + user_code: string; + device_code: string; + interval: number; + }; + d.notify(`Open ${deviceData.verification_uri}`); + d.notify(`Enter code: ${deviceData.user_code}`); + + while (true) { + const response = await fetchWithProxy(d.fetchImpl, tokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": userAgent(), + }, + body: JSON.stringify({ + client_id: COPILOT_CLIENT_ID, + device_code: deviceData.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + if (!response.ok) { + throw new Error( + `GitHub Copilot token polling failed: ${response.status}`, + ); + } + + const data = (await response.json()) as { + access_token?: string; + error?: string; + interval?: number; + }; + if (data.access_token) { + const auth: OAuthAuth = { + type: "oauth", + access: data.access_token, + refresh: data.access_token, + expires: 0, + ...(opts.enterpriseUrl ? { enterprise_url: domain } : {}), + }; + await d.authStore.set("copilot", auth); + return auth; + } + if (data.error && data.error !== "authorization_pending") { + if (data.error !== "slow_down") { + throw new Error(`GitHub Copilot authorization failed: ${data.error}`); + } + } + const interval = + data.error === "slow_down" + ? (data.interval ?? deviceData.interval + 5) + : deviceData.interval; + await d.sleep(interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS); + } +} diff --git a/llm-client/src/providers/auth-store.test.ts b/llm-client/src/providers/auth-store.test.ts new file mode 100644 index 0000000..d339c81 --- /dev/null +++ b/llm-client/src/providers/auth-store.test.ts @@ -0,0 +1,133 @@ +import { test, expect, afterEach } from "bun:test"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { authFilePath, createFileAuthStore } from "./auth-store"; + +const tmpDirs: string[] = []; +function freshAuthPath(): string { + const d = mkdtempSync(join(tmpdir(), "aimdware-authstore-")); + tmpDirs.push(d); + // nested so we also exercise directory creation + return join(d, "state", "auth.json"); +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +test("del removes a stored provider entry", async () => { + const store = createFileAuthStore(freshAuthPath()); + await store.set("codex", { type: "oauth", refresh: "r", expires: 0 }); + expect(await store.get("codex")).toBeDefined(); + + await store.del("codex"); + + expect(await store.get("codex")).toBeUndefined(); +}); + +test("del leaves other providers intact", async () => { + const store = createFileAuthStore(freshAuthPath()); + await store.set("codex", { type: "oauth", refresh: "rc", expires: 0 }); + await store.set("copilot", { type: "oauth", refresh: "rp", expires: 0 }); + + await store.del("codex"); + + expect(await store.get("codex")).toBeUndefined(); + expect(await store.get("copilot")).toBeDefined(); +}); + +test("auth.json is written owner-only (0600) inside a 0700 directory", async () => { + const path = freshAuthPath(); + const store = createFileAuthStore(path); + await store.set("codex", { type: "oauth", refresh: "secret", expires: 0 }); + + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(dirname(path)).mode & 0o777).toBe(0o700); +}); + +test("tightens an already-existing parent directory to 0700", async () => { + const dir = mkdtempSync(join(tmpdir(), "aimdware-authdir-")); + tmpDirs.push(dir); + chmodSync(dir, 0o755); // simulate a pre-existing, loosely-permissioned cache dir + const store = createFileAuthStore(join(dir, "auth.json")); + + await store.set("codex", { type: "oauth", refresh: "secret", expires: 0 }); + + expect(statSync(dir).mode & 0o777).toBe(0o700); +}); + +test("does not alter the shared cache directory that holds other state", async () => { + // Mirror main.ts: cacheDir holds records/ + queue.db alongside the auth file. + const cacheDir = mkdtempSync(join(tmpdir(), "aimdware-cache-")); + tmpDirs.push(cacheDir); + chmodSync(cacheDir, 0o755); + mkdirSync(join(cacheDir, "records")); + + const store = createFileAuthStore(authFilePath(cacheDir)); + await store.set("codex", { type: "oauth", refresh: "secret", expires: 0 }); + + // The shared cache dir must be left untouched; only the credential's own + // directory is locked to 0700. + expect(statSync(cacheDir).mode & 0o777).toBe(0o755); + expect(statSync(dirname(authFilePath(cacheDir))).mode & 0o777).toBe(0o700); +}); + +test("withLock serializes concurrent critical sections and returns the result", async () => { + const store = createFileAuthStore(freshAuthPath(), { lockPollMs: 5 }); + const order: string[] = []; + const section = (tag: string) => + store.withLock!(async () => { + order.push(`${tag}-start`); + await new Promise((r) => setTimeout(r, 20)); + order.push(`${tag}-end`); + return tag; + }); + + const results = await Promise.all([section("A"), section("B")]); + + expect(results.sort()).toEqual(["A", "B"]); + // The two sections must not interleave: whoever starts first also ends + // before the other starts. + expect(order[1]).toBe(`${order[0]![0]}-end`); +}); + +test("withLock steals a stale lock left behind by a crashed process", async () => { + const path = freshAuthPath(); + const lockPath = `${path}.lock`; + mkdirSync(dirname(lockPath), { recursive: true }); + writeFileSync(lockPath, ""); + const longAgo = new Date(Date.now() - 120_000); + utimesSync(lockPath, longAgo, longAgo); + const store = createFileAuthStore(path, { + lockStaleMs: 30_000, + lockPollMs: 5, + }); + + let ran = false; + await store.withLock!(async () => { + ran = true; + }); + + expect(ran).toBe(true); +}); + +test("concurrent set calls do not lose provider entries", async () => { + const store = createFileAuthStore(freshAuthPath()); + + await Promise.all([ + store.set("codex", { type: "oauth", refresh: "rc", expires: 0 }), + store.set("copilot", { type: "oauth", refresh: "rp", expires: 0 }), + ]); + + expect(await store.get("codex")).toBeDefined(); + expect(await store.get("copilot")).toBeDefined(); +}); diff --git a/llm-client/src/providers/auth-store.ts b/llm-client/src/providers/auth-store.ts new file mode 100644 index 0000000..7597d10 --- /dev/null +++ b/llm-client/src/providers/auth-store.ts @@ -0,0 +1,136 @@ +import { chmod, mkdir, open, readFile, stat, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { writeAtomic } from "../util"; +import type { ProviderId } from "./plugin"; + +export type OAuthAuth = { + type: "oauth"; + access?: string; + refresh: string; + expires: number; + account_id?: string; + accountId?: string; + enterprise_url?: string; + enterpriseUrl?: string; +}; + +export type ProviderAuth = OAuthAuth; + +export type AuthStore = { + get(id: ProviderId): Promise; + set(id: ProviderId, auth: ProviderAuth): Promise; + del(id: ProviderId): Promise; + // Run `fn` while holding a cross-process lock on the credential store, so a + // read-modify-write (e.g. a token refresh) is atomic across router processes + // sharing the same cache. Optional: stores with no shared backing (in-memory + // test doubles) need no lock and may omit it. + withLock?(fn: () => Promise): Promise; +}; + +type AuthFile = { + providers?: Partial>; +}; + +/** + * Location of the credential file given the router's cache dir. + * + * Credentials live in their own subdirectory so the store can lock it to 0700 + * without touching the shared cache dir (which also holds records/ + queue.db, + * may be a symlink, or may not be owned by this user). + */ +export function authFilePath(cacheDir: string): string { + return join(cacheDir, "auth", "auth.json"); +} + +export function createFileAuthStore( + path: string, + opts?: { lockStaleMs?: number; lockPollMs?: number }, +): AuthStore { + const lockPath = `${path}.lock`; + const lockStaleMs = opts?.lockStaleMs ?? 30_000; + const lockPollMs = opts?.lockPollMs ?? 50; + + async function readAll(): Promise { + try { + return JSON.parse(await readFile(path, "utf-8")) as AuthFile; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw e; + } + } + + // Serialize mutations through a promise chain: set/del are read-modify-write, + // so concurrent callers would otherwise race on readAll() and clobber each + // other's provider entry. A failed write must not break the chain for later + // writers, hence the `.catch`. + let writeChain: Promise = Promise.resolve(); + function mutate(transform: (file: AuthFile) => AuthFile): Promise { + const run = writeChain.then(async () => { + const next = transform(await readAll()); + const dir = dirname(path); + await mkdir(dir, { recursive: true, mode: 0o700 }); + // mkdir's mode only applies to dirs it creates; tighten an existing one + // (e.g. a cache dir made earlier with default perms) so the credential + // file's directory is never group/world-traversable. + await chmod(dir, 0o700); + await writeAtomic(path, new TextEncoder().encode(JSON.stringify(next)), { + mode: 0o600, + }); + }); + writeChain = run.catch(() => {}); + return run; + } + + return { + async get(id) { + return (await readAll()).providers?.[id]; + }, + set(id, auth) { + return mutate((file) => ({ + ...file, + providers: { ...file.providers, [id]: auth }, + })); + }, + del(id) { + return mutate((file) => { + const providers = { ...file.providers }; + delete providers[id]; + return { ...file, providers }; + }); + }, + async withLock(fn) { + await acquireLock(); + try { + return await fn(); + } finally { + await unlink(lockPath).catch(() => {}); + } + }, + }; + + // Cross-process mutex via an exclusive-create lock file. A holder that + // crashed leaves the file behind; the next acquirer reclaims it once the + // file is older than `lockStaleMs`. + async function acquireLock(): Promise { + while (true) { + try { + await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); + const handle = await open(lockPath, "wx", 0o600); + await handle.close(); + return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + try { + const st = await stat(lockPath); + if (Date.now() - st.mtimeMs > lockStaleMs) { + await unlink(lockPath).catch(() => {}); + continue; + } + } catch { + continue; // lock vanished between open and stat; retry immediately + } + await new Promise((r) => setTimeout(r, lockPollMs)); + } + } + } +} diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts new file mode 100644 index 0000000..2052353 --- /dev/null +++ b/llm-client/src/providers/codex.ts @@ -0,0 +1,328 @@ +import type { AuthStore, OAuthAuth } from "./auth-store"; +import { + UnsupportedProviderProtocolError, + type ProviderFetchOpts, + type ProviderRuntime, +} from "./plugin"; +import { fetchWithProxy, userAgent } from "./plugin"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const ISSUER = "https://auth.openai.com"; +const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; + +// Refresh slightly before the server-side expiry so a request that starts just +// before the boundary doesn't lose a race with upstream and 401. +const EXPIRY_SKEW_MS = 60_000; + +const REAUTH_HINT = + "Run `aimdware-router auth login codex` to re-authenticate."; + +type TokenResponse = { + id_token?: string; + access_token: string; + refresh_token: string; + expires_in?: number; +}; + +type JwtClaims = { + chatgpt_account_id?: string; + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string; + }; +}; + +/** + * Thrown when the stored refresh token is no longer accepted by OpenAI + * (revoked, expired, password change). The only remedy is an interactive + * re-login, so the message always points there and the caller clears the + * stale credential. + */ +export class CodexReauthRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexReauthRequiredError"; + } +} + +export type ParsedTokenResponse = { + id_token?: string; + access_token: string; + refresh_token: string; + expires_in?: number; +}; + +/** + * Validate a raw OAuth token payload before it is trusted/persisted. + * + * Refresh responses may legitimately omit `refresh_token`; pass the previous + * one so it is preserved rather than dropped. Login responses must include it, + * so callers there omit `previousRefresh` and a missing token is an error. + */ +export function parseTokenResponse( + raw: unknown, + previousRefresh?: string, +): ParsedTokenResponse { + if (!raw || typeof raw !== "object") { + throw new Error("Codex token response was not a JSON object"); + } + const obj = raw as Record; + + const access = obj.access_token; + if (typeof access !== "string" || access.length === 0) { + throw new Error("Codex token response is missing access_token"); + } + + const refreshRaw = obj.refresh_token; + let refresh: string; + if (typeof refreshRaw === "string" && refreshRaw.length > 0) { + refresh = refreshRaw; + } else if (previousRefresh) { + refresh = previousRefresh; + } else { + throw new Error("Codex token response is missing refresh_token"); + } + + let expires_in: number | undefined; + if (obj.expires_in !== undefined) { + if ( + typeof obj.expires_in !== "number" || + !Number.isFinite(obj.expires_in) || + obj.expires_in <= 0 + ) { + throw new Error("Codex token response has an invalid expires_in"); + } + expires_in = obj.expires_in; + } + + return { + id_token: typeof obj.id_token === "string" ? obj.id_token : undefined, + access_token: access, + refresh_token: refresh, + expires_in, + }; +} + +export type CodexProviderOpts = ProviderFetchOpts & { + authStore: AuthStore; +}; + +function parseJwtClaims(token: string): JwtClaims | undefined { + const parts = token.split("."); + if (parts.length !== 3 || parts[1] === undefined) return undefined; + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString()); + } catch { + return undefined; + } +} + +function extractAccountIdFromClaims(claims: JwtClaims): string | undefined { + // Only trust an explicit ChatGPT account id. The earlier `organizations[0].id` + // fallback is a different identifier and can mislabel the `ChatGPT-Account-Id` + // header; omitting the header is safer than sending the wrong value. + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id + ); +} + +export function extractCodexAccountId( + tokens: Pick, +): string | undefined { + for (const token of [tokens.id_token, tokens.access_token]) { + if (!token) continue; + const claims = parseJwtClaims(token); + const accountId = claims && extractAccountIdFromClaims(claims); + if (accountId) return accountId; + } + return undefined; +} + +function isTerminalAuthFailure(status: number, body: string): boolean { + if (status === 401 || status === 403) return true; + // A 400 is only terminal when it carries an OAuth invalid-credential error; + // other 400s are treated as transient/unexpected. + if (status === 400) { + return /invalid_grant|invalid_request|invalid_client|invalid_token/.test( + body, + ); + } + return false; +} + +async function refreshAccessToken( + refreshToken: string, + opts: Required, +): Promise { + const response = await fetchWithProxy( + opts.fetchImpl, + `${ISSUER}/oauth/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": userAgent(), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }, + ); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + if (isTerminalAuthFailure(response.status, detail)) { + throw new CodexReauthRequiredError( + `Codex subscription token is no longer valid (refresh rejected: ${response.status}). ${REAUTH_HINT}`, + ); + } + throw new Error(`Codex token refresh failed: ${response.status}`); + } + return parseTokenResponse(await response.json(), refreshToken); +} + +type RefreshGate = { inFlight: Promise | null }; + +async function currentAuth( + opts: CodexProviderOpts, + gate: RefreshGate, +): Promise { + const now = opts.now ?? Date.now; + const fetchImpl = + opts.fetchImpl ?? (fetch as unknown as ProviderFetchOpts["fetchImpl"]); + const readAuth = async (): Promise => { + const auth = await opts.authStore.get("codex"); + if (!auth || auth.type !== "oauth") { + throw new Error(`Codex subscription is not logged in. ${REAUTH_HINT}`); + } + return auth; + }; + const isFresh = (auth: OAuthAuth): boolean => + Boolean(auth.access) && auth.expires > now() + EXPIRY_SKEW_MS; + + const cached = await readAuth(); + if (isFresh(cached)) return cached; + + // Single-flight: concurrent expired requests share one refresh, so a rotated + // refresh token is fetched and persisted exactly once. The gated body + // re-reads the store first — a request that arrives just after another + // refresh completed must pick up the rotated token rather than refresh again + // with the now-invalid one. + if (gate.inFlight) return gate.inFlight; + // Cross-process critical section: when several routers share one cache, only + // one refreshes at a time. The loser, once it acquires the lock, re-reads + // (below) and finds the freshly-rotated token, so it never refreshes a + // spent one. Stores with no shared backing run the body directly. + const withLock = + opts.authStore.withLock?.bind(opts.authStore) ?? + ((fn: () => Promise): Promise => fn()); + const refresh = async (): Promise => { + let usedRefresh = ""; + try { + const auth = await readAuth(); + if (isFresh(auth)) return auth; + usedRefresh = auth.refresh; + const tokens = await refreshAccessToken(auth.refresh, { + now, + fetchImpl: fetchImpl!, + }); + const next: OAuthAuth = { + type: "oauth", + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: now() + (tokens.expires_in ?? 3600) * 1000, + account_id: + extractCodexAccountId(tokens) ?? auth.account_id ?? auth.accountId, + }; + await opts.authStore.set("codex", next); + return next; + } catch (e) { + if (e instanceof CodexReauthRequiredError) { + // Another router process (sharing this cache) may have rotated the + // token between our read and our failed refresh — single-use refresh + // tokens mean the loser of a concurrent refresh gets invalid_grant. + // If the stored token has since changed, adopt the fresh one rather + // than deleting the credential everyone is now using. + const latest = await opts.authStore.get("codex"); + if ( + latest?.type === "oauth" && + latest.refresh !== usedRefresh && + isFresh(latest) + ) { + return latest; + } + // The stored token is still the one we failed with: a genuine + // revocation. Drop it so `auth status` reflects reality. + if ( + !latest || + latest.type !== "oauth" || + latest.refresh === usedRefresh + ) { + await opts.authStore.del("codex"); + } + } + throw e; + } + }; + gate.inFlight = (async () => { + try { + return await withLock(refresh); + } finally { + gate.inFlight = null; + } + })(); + return gate.inFlight; +} + +function codexRequestBody(body: ArrayBuffer | undefined): RequestInit["body"] { + if (!body) return body; + const text = new TextDecoder().decode(body); + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + delete (parsed as { max_output_tokens?: unknown }).max_output_tokens; + return JSON.stringify(parsed); + } + } catch { + // Forward non-JSON bodies unchanged so upstream produces the protocol error. + } + return body; +} + +export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { + const gate: RefreshGate = { inFlight: null }; + + const prepareResponses = async ( + input: Parameters[0], + ) => { + const auth = await currentAuth(opts, gate); + const headers = new Headers(input.headers); + headers.delete("authorization"); + headers.delete("Authorization"); + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${auth.access}`); + headers.set("originator", "aimdware-router"); + headers.set("User-Agent", userAgent()); + const accountId = auth.account_id ?? auth.accountId; + if (accountId) headers.set("ChatGPT-Account-Id", accountId); + + return { + url: new URL(CODEX_API_ENDPOINT), + method: input.method, + headers, + body: codexRequestBody(input.body), + }; + }; + + return { + id: "codex", + label: "ChatGPT Codex subscription", + async prepareChat() { + throw new UnsupportedProviderProtocolError( + "provider codex does not support /v1/chat/completions; use /v1/responses", + ); + }, + prepareResponses, + }; +} diff --git a/llm-client/src/providers/copilot.ts b/llm-client/src/providers/copilot.ts new file mode 100644 index 0000000..4de9957 --- /dev/null +++ b/llm-client/src/providers/copilot.ts @@ -0,0 +1,137 @@ +import type { AuthStore, OAuthAuth } from "./auth-store"; +import type { ProviderRuntime } from "./plugin"; +import { openAICompatibleUrl, userAgent } from "./plugin"; + +const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"; + +export type CopilotProviderOpts = { + authStore: AuthStore; +}; + +function normalizeDomain(url: string): string { + return url.replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +function base(enterpriseUrl?: string): string { + return enterpriseUrl + ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` + : "https://api.githubcopilot.com"; +} + +function isVisionBody(body: ArrayBuffer | undefined): boolean { + return classifyBody(body).isVision; +} + +function isAgentBody(body: ArrayBuffer | undefined): boolean { + return classifyBody(body).isAgent; +} + +function classifyBody(body: ArrayBuffer | undefined): { + isVision: boolean; + isAgent: boolean; +} { + if (!body) return { isVision: false, isAgent: false }; + try { + const parsed = JSON.parse(new TextDecoder().decode(body)) as { + messages?: Array<{ role?: unknown; content?: unknown }>; + input?: Array<{ role?: unknown; content?: unknown }>; + }; + if (Array.isArray(parsed.input)) { + const last = parsed.input.at(-1); + return { + isVision: parsed.input.some((msg) => + hasContentPart(msg.content, ["input_image"]), + ), + isAgent: last?.role !== "user" || isSyntheticAttachmentMessage(last), + }; + } + if (Array.isArray(parsed.messages)) { + const last = parsed.messages.at(-1); + return { + isVision: parsed.messages.some((msg) => + hasContentPart(msg.content, ["image_url"]), + ), + isAgent: last?.role !== "user" || isSyntheticAttachmentMessage(last), + }; + } + } catch {} + return { isVision: false, isAgent: false }; +} + +function hasContentPart(content: unknown, types: readonly string[]): boolean { + return ( + Array.isArray(content) && + content.some( + (part) => + typeof part === "object" && + part !== null && + types.includes(String((part as { type?: unknown }).type)), + ) + ); +} + +function isSyntheticAttachmentMessage(msg: unknown): boolean { + if (typeof msg !== "object" || msg === null) return false; + const content = (msg as { content?: unknown }).content; + if (typeof content === "string") + return content === SYNTHETIC_ATTACHMENT_PROMPT; + return ( + Array.isArray(content) && + content.some((part) => { + if (typeof part !== "object" || part === null) return false; + const typed = part as { type?: unknown; text?: unknown }; + return ( + (typed.type === "text" || typed.type === "input_text") && + typed.text === SYNTHETIC_ATTACHMENT_PROMPT + ); + }) + ); +} + +async function currentAuth(authStore: AuthStore): Promise { + const auth = await authStore.get("copilot"); + if (!auth || auth.type !== "oauth") { + throw new Error( + "GitHub Copilot subscription is not logged in. Run `aimdware-router auth login copilot` first.", + ); + } + return auth; +} + +export function createCopilotProvider( + opts: CopilotProviderOpts, +): ProviderRuntime { + const prepare = async ( + input: Parameters[0], + ) => { + const auth = await currentAuth(opts.authStore); + const headers = new Headers(input.headers); + headers.delete("authorization"); + headers.delete("Authorization"); + headers.delete("x-api-key"); + headers.set("authorization", `Bearer ${auth.refresh}`); + headers.set("User-Agent", userAgent()); + headers.set("Openai-Intent", "conversation-edits"); + headers.set("x-initiator", isAgentBody(input.body) ? "agent" : "user"); + if (isVisionBody(input.body)) { + headers.set("Copilot-Vision-Request", "true"); + } + + return { + url: openAICompatibleUrl( + base(auth.enterprise_url ?? auth.enterpriseUrl), + input.inboundUrl, + ), + method: input.method, + headers, + body: input.body, + }; + }; + + return { + id: "copilot", + label: "GitHub Copilot subscription", + prepareChat: prepare, + prepareResponses: prepare, + }; +} diff --git a/llm-client/src/providers/index.ts b/llm-client/src/providers/index.ts new file mode 100644 index 0000000..b9df446 --- /dev/null +++ b/llm-client/src/providers/index.ts @@ -0,0 +1,26 @@ +import type { Config } from "../config"; +import type { AuthStore } from "./auth-store"; +import { createCodexProvider } from "./codex"; +import { createCopilotProvider } from "./copilot"; +import { createOpenAIProvider } from "./openai"; +import type { ProviderRuntime } from "./plugin"; + +export function createProvider( + upstream: Config["upstream"], + authStore: AuthStore, +): ProviderRuntime { + switch (upstream.plugin) { + case "openai": + if (!upstream.api_key) { + throw new Error("upstream.api_key is required"); + } + return createOpenAIProvider({ + base_url: upstream.base_url, + api_key: upstream.api_key, + }); + case "codex": + return createCodexProvider({ authStore }); + case "copilot": + return createCopilotProvider({ authStore }); + } +} diff --git a/llm-client/src/providers/openai.ts b/llm-client/src/providers/openai.ts new file mode 100644 index 0000000..fb5ade5 --- /dev/null +++ b/llm-client/src/providers/openai.ts @@ -0,0 +1,31 @@ +import type { ProviderRuntime } from "./plugin"; +import { openAICompatibleUrl } from "./plugin"; + +export type OpenAIProviderConfig = { + base_url: string; + api_key: string; +}; + +export function createOpenAIProvider( + config: OpenAIProviderConfig, +): ProviderRuntime { + const prepare = async ( + input: Parameters[0], + ) => { + const headers = new Headers(input.headers); + headers.set("authorization", `Bearer ${config.api_key}`); + return { + url: openAICompatibleUrl(config.base_url, input.inboundUrl), + method: input.method, + headers, + body: input.body, + }; + }; + + return { + id: "openai", + label: "OpenAI-compatible API", + prepareChat: prepare, + prepareResponses: prepare, + }; +} diff --git a/llm-client/src/providers/plugin.ts b/llm-client/src/providers/plugin.ts new file mode 100644 index 0000000..e5a8b48 --- /dev/null +++ b/llm-client/src/providers/plugin.ts @@ -0,0 +1,64 @@ +import type { FetchLike } from "../http/proxy"; +import { getProxyForUrl } from "../http/net"; + +export type ProviderId = "openai" | "codex" | "copilot"; + +export type ProviderPrepareInput = { + inboundUrl: URL; + method: string; + headers: Headers; + body: ArrayBuffer | undefined; +}; + +export type ProviderPreparedRequest = { + url: URL; + method?: string; + headers: Headers; + body?: RequestInit["body"] | null; +}; + +export type ProviderRuntime = { + id: ProviderId; + label: string; + prepareChat(input: ProviderPrepareInput): Promise; + prepareResponses( + input: ProviderPrepareInput, + ): Promise; +}; + +export class UnsupportedProviderProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedProviderProtocolError"; + } +} + +export type ProviderFetchOpts = { + fetchImpl?: FetchLike; + now?: () => number; +}; + +export function openAICompatibleUrl(baseUrl: string, inboundUrl: URL): URL { + const base = baseUrl.replace(/\/+$/, ""); + let path = inboundUrl.pathname; + if (base.endsWith("/v1") && path.startsWith("/v1/")) { + path = path.slice("/v1".length); + } + return new URL(base + path + inboundUrl.search); +} + +export function userAgent(): string { + return `aimdware-router/${process.env.npm_package_version ?? "0.1.0"}`; +} + +export function fetchWithProxy( + fetchImpl: FetchLike, + input: string | URL | Request, + init: RequestInit = {}, +): Promise { + const target = input instanceof Request ? input.url : input; + const proxy = getProxyForUrl(target); + const next: RequestInit & { proxy?: string } = { ...init }; + if (proxy !== undefined) next.proxy = proxy; + return fetchImpl(input, next); +} diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts new file mode 100644 index 0000000..c159b0e --- /dev/null +++ b/llm-client/src/providers/provider.test.ts @@ -0,0 +1,546 @@ +import { test, expect } from "bun:test"; +import { proxyChat, proxyResponses, type FetchLike } from "../http/proxy"; +import { createCodexProvider, extractCodexAccountId } from "./codex"; +import { createCopilotProvider } from "./copilot"; +import { userAgent } from "./plugin"; +import type { AuthStore, ProviderAuth } from "./auth-store"; + +function jwt(claims: Record): string { + const part = (o: unknown) => + Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${part({ alg: "none" })}.${part(claims)}.sig`; +} + +function expiredCodexStore(): AuthStore { + return authStore({ + type: "oauth", + access: "expired-access", + refresh: "refresh-token", + expires: 1, + account_id: "acct-old", + }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const responsesInput = () => ({ + inboundUrl: new URL("http://router-local/v1/responses"), + method: "POST", + headers: new Headers(), + body: undefined, +}); + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +] as const; + +function snapshotProxyEnv(): Partial< + Record<(typeof PROXY_ENV_KEYS)[number], string> +> { + const snapshot: Partial> = {}; + for (const key of PROXY_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) snapshot[key] = value; + delete process.env[key]; + } + return snapshot; +} + +function restoreProxyEnv( + snapshot: Partial>, +): void { + for (const key of PROXY_ENV_KEYS) { + const value = snapshot[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function authStore(initial: ProviderAuth): AuthStore { + let value: ProviderAuth | undefined = initial; + return { + async get(id) { + expect(["codex", "copilot"]).toContain(id); + return value; + }, + async set(_id, next) { + value = next; + }, + async del(_id) { + value = undefined; + }, + }; +} + +test("codex provider refreshes oauth and rewrites Responses requests to the Codex endpoint", async () => { + const store = authStore({ + type: "oauth", + access: "expired-access", + refresh: "refresh-token", + expires: 1, + account_id: "acct-old", + }); + const upstreamCalls: Array<{ url: string; headers: Record }> = + []; + + const refreshFetch: FetchLike = async () => + new Response( + JSON.stringify({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + const upstreamFetch: FetchLike = async (input, init) => { + const headers: Record = {}; + new Headers(init?.headers).forEach((value, key) => { + headers[key] = value; + }); + upstreamCalls.push({ + url: input instanceof URL ? input.toString() : String(input), + headers, + }); + return new Response('{"ok":true}', { status: 200 }); + }; + + await proxyResponses( + new Request("http://router-local/v1/responses", { + method: "POST", + headers: { + authorization: "Bearer client-token", + "x-api-key": "client-api-key", + }, + body: JSON.stringify({ model: "gpt-5.3-codex", input: [] }), + }), + createCodexProvider({ authStore: store, fetchImpl: refreshFetch }), + { fetchImpl: upstreamFetch }, + ); + + expect(upstreamCalls).toHaveLength(1); + expect(upstreamCalls[0]!.url).toBe( + "https://chatgpt.com/backend-api/codex/responses", + ); + expect(upstreamCalls[0]!.headers.authorization).toBe("Bearer fresh-access"); + expect(upstreamCalls[0]!.headers["x-api-key"]).toBeUndefined(); + expect(upstreamCalls[0]!.headers["chatgpt-account-id"]).toBe("acct-old"); +}); + +test("codex provider refresh uses HTTPS_PROXY", async () => { + const originalProxy = snapshotProxyEnv(); + process.env.HTTPS_PROXY = "http://127.0.0.1:10870"; + const store = authStore({ + type: "oauth", + access: "expired-access", + refresh: "refresh-token", + expires: 1, + }); + const refreshCalls: Array = []; + + const refreshFetch: FetchLike = async (_input, init) => { + refreshCalls.push((init ?? {}) as RequestInit & { proxy?: string }); + return Response.json({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }); + }; + const upstreamFetch: FetchLike = async () => + new Response('{"ok":true}', { status: 200 }); + + try { + await proxyResponses( + new Request("http://router-local/v1/responses", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.3-codex", input: [] }), + }), + createCodexProvider({ authStore: store, fetchImpl: refreshFetch }), + { fetchImpl: upstreamFetch }, + ); + } finally { + restoreProxyEnv(originalProxy); + } + + expect(refreshCalls[0]!.proxy).toBe("http://127.0.0.1:10870"); +}); + +test("codex provider strips max_output_tokens before forwarding", async () => { + const store = authStore({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 600_000, + }); + const upstreamBodies: string[] = []; + const upstreamFetch: FetchLike = async (_input, init) => { + const body = init?.body; + upstreamBodies.push( + body instanceof ArrayBuffer + ? new TextDecoder().decode(body) + : String(body), + ); + return new Response('{"ok":true}', { status: 200 }); + }; + + await proxyResponses( + new Request("http://router-local/v1/responses", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.3-codex", + input: [], + max_output_tokens: 1024, + }), + }), + createCodexProvider({ authStore: store }), + { fetchImpl: upstreamFetch }, + ); + + expect(JSON.parse(upstreamBodies[0]!)).toEqual({ + model: "gpt-5.3-codex", + input: [], + }); +}); + +test("codex provider rejects Chat Completions instead of sending the wrong protocol upstream", async () => { + const store = authStore({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }); + + await expect( + proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.3-codex", messages: [] }), + }), + createCodexProvider({ authStore: store }), + ), + ).rejects.toThrow("does not support /v1/chat/completions"); +}); + +test("copilot provider targets GitHub Copilot and adds subscription headers", async () => { + const store = authStore({ + type: "oauth", + access: "gho-access", + refresh: "gho-access", + expires: 0, + }); + const upstreamCalls: Array<{ url: string; headers: Record }> = + []; + const upstreamFetch: FetchLike = async (input, init) => { + const headers: Record = {}; + new Headers(init?.headers).forEach((value, key) => { + headers[key] = value; + }); + upstreamCalls.push({ + url: input instanceof URL ? input.toString() : String(input), + headers, + }); + return new Response('{"ok":true}', { status: 200 }); + }; + + await proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.1-codex", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this" }, + { type: "image_url", image_url: { url: "data:image/png,..." } }, + ], + }, + ], + }), + }), + createCopilotProvider({ authStore: store }), + { fetchImpl: upstreamFetch }, + ); + + expect(upstreamCalls).toHaveLength(1); + expect(upstreamCalls[0]!.url).toBe( + "https://api.githubcopilot.com/v1/chat/completions", + ); + expect(upstreamCalls[0]!.headers.authorization).toBe("Bearer gho-access"); + expect(upstreamCalls[0]!.headers["openai-intent"]).toBe("conversation-edits"); + expect(upstreamCalls[0]!.headers["x-initiator"]).toBe("user"); + expect(upstreamCalls[0]!.headers["copilot-vision-request"]).toBe("true"); +}); + +test("copilot provider marks agent-initiated chat requests", async () => { + const store = authStore({ + type: "oauth", + access: "gho-access", + refresh: "gho-access", + expires: 0, + }); + const upstreamCalls: Array<{ headers: Record }> = []; + const upstreamFetch: FetchLike = async (_input, init) => { + const headers: Record = {}; + new Headers(init?.headers).forEach((value, key) => { + headers[key] = value; + }); + upstreamCalls.push({ headers }); + return new Response('{"ok":true}', { status: 200 }); + }; + + await proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.1-codex", + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "working" }, + ], + }), + }), + createCopilotProvider({ authStore: store }), + { fetchImpl: upstreamFetch }, + ); + + expect(upstreamCalls[0]!.headers["x-initiator"]).toBe("agent"); +}); + +test("codex refresh is single-flight under concurrent expired requests", async () => { + const store = expiredCodexStore(); + let refreshCalls = 0; + const refreshFetch: FetchLike = async () => { + refreshCalls++; + await new Promise((r) => setTimeout(r, 5)); + return jsonResponse({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }); + }; + const provider = createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }); + + await Promise.all([ + provider.prepareResponses(responsesInput()), + provider.prepareResponses(responsesInput()), + ]); + + expect(refreshCalls).toBe(1); +}); + +test("codex re-reads inside the refresh gate and skips a redundant refresh when another request already rotated the token", async () => { + // Simulates the production race: the outer read sees a stale/expired token, + // but by the time this request refreshes, another request has already + // rotated and persisted a fresh one. The gate body must re-read and use it + // rather than refresh again with the now-invalid refresh token. + const NOW = 1_000_000; + let gets = 0; + const store: AuthStore = { + async get() { + gets++; + return gets === 1 + ? { type: "oauth", access: "stale", refresh: "R1", expires: 1 } + : { + type: "oauth", + access: "rotated-by-other", + refresh: "R2", + expires: NOW + 600_000, + }; + }, + async set() {}, + async del() {}, + }; + let refreshCalls = 0; + const refreshFetch: FetchLike = async () => { + refreshCalls++; + return jsonResponse({ + access_token: "should-not-be-used", + refresh_token: "z", + expires_in: 3600, + }); + }; + + const prepared = await createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + now: () => NOW, + }).prepareResponses(responsesInput()); + + expect(refreshCalls).toBe(0); + expect(prepared.headers.get("authorization")).toBe("Bearer rotated-by-other"); +}); + +test("codex refreshes when the token is within the 60s expiry skew window", async () => { + const store = authStore({ + type: "oauth", + access: "soon-to-expire", + refresh: "refresh-token", + expires: Date.now() + 30_000, + }); + let refreshed = false; + const refreshFetch: FetchLike = async () => { + refreshed = true; + return jsonResponse({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }); + }; + + await createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()); + + expect(refreshed).toBe(true); +}); + +test("codex refresh sends a User-Agent header", async () => { + const store = expiredCodexStore(); + const seen: { ua: string | null } = { ua: null }; + const refreshFetch: FetchLike = async (_input, init) => { + seen.ua = new Headers(init?.headers).get("user-agent"); + return jsonResponse({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }); + }; + + await createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()); + + expect(seen.ua).toBe(userAgent()); +}); + +test("codex refresh preserves the old refresh token when the response omits one", async () => { + const store = expiredCodexStore(); + const refreshFetch: FetchLike = async () => + jsonResponse({ access_token: "fresh-access", expires_in: 3600 }); + + await createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()); + + expect(await store.get("codex")).toMatchObject({ + access: "fresh-access", + refresh: "refresh-token", + }); +}); + +test("codex refresh rejects a token response missing access_token without persisting", async () => { + const store = expiredCodexStore(); + const refreshFetch: FetchLike = async () => + jsonResponse({ refresh_token: "fresh-refresh", expires_in: 3600 }); + + await expect( + createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()), + ).rejects.toThrow(/access_token/); + + expect(await store.get("codex")).toMatchObject({ access: "expired-access" }); +}); + +test("codex surfaces a re-login instruction and clears auth when the refresh token is rejected", async () => { + const store = expiredCodexStore(); + const refreshFetch: FetchLike = async () => + jsonResponse({ error: "invalid_grant" }, 400); + + await expect( + createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()), + ).rejects.toThrow(/auth login codex/); + + expect(await store.get("codex")).toBeUndefined(); +}); + +test("codex treats a 401 refresh as terminal re-login", async () => { + const store = expiredCodexStore(); + const refreshFetch: FetchLike = async () => + new Response("nope", { status: 401 }); + + await expect( + createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + }).prepareResponses(responsesInput()), + ).rejects.toThrow(/auth login codex/); +}); + +test("codex adopts a concurrently-rotated token instead of deleting on invalid_grant", async () => { + // Multiple router processes share one cache. Both read expired R1; another + // process rotates to R2 and persists. Our refresh of R1 then fails with + // invalid_grant — we must adopt the stored R2, NOT delete the credential. + const NOW = 1_000_000; + let gets = 0; + let dels = 0; + const rotated = { + type: "oauth" as const, + access: "rotated-by-other-process", + refresh: "R2", + expires: NOW + 600_000, + }; + const store: AuthStore = { + async get() { + gets++; + return gets <= 2 + ? { type: "oauth", access: "stale", refresh: "R1", expires: 1 } + : rotated; + }, + async set() {}, + async del() { + dels++; + }, + }; + const refreshFetch: FetchLike = async () => + jsonResponse({ error: "invalid_grant" }, 400); + + const prepared = await createCodexProvider({ + authStore: store, + fetchImpl: refreshFetch, + now: () => NOW, + }).prepareResponses(responsesInput()); + + expect(prepared.headers.get("authorization")).toBe( + "Bearer rotated-by-other-process", + ); + expect(dels).toBe(0); +}); + +test("extractCodexAccountId uses explicit chatgpt_account_id but ignores organizations fallback", () => { + expect( + extractCodexAccountId({ + access_token: "x", + id_token: jwt({ chatgpt_account_id: "acct-123" }), + }), + ).toBe("acct-123"); + + expect( + extractCodexAccountId({ + access_token: "x", + id_token: jwt({ organizations: [{ id: "org-999" }] }), + }), + ).toBeUndefined(); +}); diff --git a/llm-client/src/recording/capture.test.ts b/llm-client/src/recording/capture.test.ts new file mode 100644 index 0000000..f51b398 --- /dev/null +++ b/llm-client/src/recording/capture.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from "bun:test"; +import { captureChat, tryParseJSON, decodeBytes } from "./capture"; + +function enc(s: string): Uint8Array { + return new TextEncoder().encode(s); +} +function dec(b: Uint8Array): string { + return new TextDecoder().decode(b); +} + +function makeStreamingResponse(chunks: string[], opts: ResponseInit = {}) { + const stream = new ReadableStream({ + async start(ctrl) { + for (const c of chunks) { + ctrl.enqueue(enc(c)); + await Bun.sleep(2); + } + ctrl.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + ...opts, + }); +} + +test("captureChat: non-streaming — clientResponse byte-exact, captured request + response bytes returned", async () => { + const requestText = + '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'; + const responseText = '{"id":"x","choices":[{"message":{"content":"hello"}}]}'; + + const upstreamRes = new Response(responseText, { + status: 200, + headers: { "content-type": "application/json" }, + }); + const { clientResponse, captureP } = captureChat( + enc(requestText), + upstreamRes, + ); + + expect(await clientResponse.text()).toBe(responseText); + + const r = await captureP; + expect(r.upstream_status).toBe(200); + expect(dec(r.request_bytes)).toBe(requestText); + expect(dec(r.response_bytes)).toBe(responseText); + expect(r.record_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); +}); + +test("captureChat: streaming — clientResponse byte-for-byte + response_bytes is the joined raw SSE", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n', + "data: [DONE]\n\n", + ]; + const upstreamRes = makeStreamingResponse(chunks); + const { clientResponse, captureP } = captureChat(enc("req"), upstreamRes); + + expect(await clientResponse.text()).toBe(chunks.join("")); + + const r = await captureP; + expect(dec(r.response_bytes)).toBe(chunks.join("")); +}); + +test("captureChat: 204 / empty body — captures empty response_bytes without hanging", async () => { + const upstreamRes = new Response(null, { status: 204 }); + const { clientResponse, captureP } = captureChat(enc("x"), upstreamRes); + expect(clientResponse.status).toBe(204); + + const r = await captureP; + expect(r.upstream_status).toBe(204); + expect(r.response_bytes.byteLength).toBe(0); +}); + +test("captureChat: still captures full response even if client cancels mid-stream", async () => { + const chunks = ["chunk1\n", "chunk2\n", "chunk3\n"]; + const upstreamRes = makeStreamingResponse(chunks); + + const { clientResponse, captureP } = captureChat(enc("req"), upstreamRes); + const reader = clientResponse.body!.getReader(); + await reader.read(); + await reader.cancel("client gone"); + + const r = await captureP; + expect(dec(r.response_bytes)).toBe(chunks.join("")); +}); + +test("tryParseJSON: parses valid JSON / returns string for invalid", () => { + expect(tryParseJSON('{"a":1}')).toEqual({ a: 1 }); + expect(tryParseJSON("not json")).toBe("not json"); +}); + +test("decodeBytes: round-trips utf-8", () => { + expect(decodeBytes(new TextEncoder().encode("héllo"))).toBe("héllo"); +}); diff --git a/llm-client/src/recording/capture.ts b/llm-client/src/recording/capture.ts new file mode 100644 index 0000000..940f96e --- /dev/null +++ b/llm-client/src/recording/capture.ts @@ -0,0 +1,100 @@ +/** + * Capture a chat completion exchange: forward the upstream response to + * the client unchanged, while accumulating the request + response bytes + * for later session-blob assembly. The blob itself (and its hash) is + * built downstream — capture only emits raw inputs so the caller can + * fold them into a session-wide rollup. + */ +export type CaptureResult = { + record_id: string; + ts: Date; + upstream_status: number; + request_bytes: Uint8Array; + response_bytes: Uint8Array; +}; + +export function captureChat( + requestBytes: Uint8Array, + upstreamRes: Response, +): { clientResponse: Response; captureP: Promise } { + const ts = new Date(); + const record_id = crypto.randomUUID(); + const upstream_status = upstreamRes.status; + + if (!upstreamRes.body) { + const captureP = Promise.resolve({ + record_id, + ts, + upstream_status, + request_bytes: requestBytes, + response_bytes: new Uint8Array(0), + }); + return { + clientResponse: new Response(null, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: upstreamRes.headers, + }), + captureP, + }; + } + + const [forward, capture] = upstreamRes.body.tee(); + const clientResponse = new Response(forward, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: upstreamRes.headers, + }); + + const captureP = (async () => { + const reader = capture.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return { + record_id, + ts, + upstream_status, + request_bytes: requestBytes, + response_bytes: concatChunks(chunks), + }; + })(); + + return { clientResponse, captureP }; +} + +/** + * Parse `s` as JSON if it is valid JSON; otherwise return the original + * string. Used so the session blob holds structured request/response + * objects when possible instead of opaque escaped strings. + */ +export function tryParseJSON(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + +export function decodeBytes(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); +} + +function concatChunks(chunks: Uint8Array[]): Uint8Array { + let total = 0; + for (const c of chunks) total += c.byteLength; + const merged = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + merged.set(c, off); + off += c.byteLength; + } + return merged; +} diff --git a/llm-client/src/recording/large-payload.test.ts b/llm-client/src/recording/large-payload.test.ts new file mode 100644 index 0000000..e77635c --- /dev/null +++ b/llm-client/src/recording/large-payload.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for the router's behaviour under large payloads (≥1 MB). + * + * Realistic agent platforms (opencode + plugins + MCP) routinely emit + * 1 MB+ tool schemas and can grow `messages` to 1 MB+ inside a single + * session. None of our code paths impose explicit size limits; this + * suite pins that down with concrete numbers so a future "let's add + * a size cap" refactor doesn't silently break the audit promise. + * + * Sizes here are deliberately chosen at the edge of "realistic agent + * load" (1-5 MB), NOT pathological (100 MB). The pipeline should hold + * up to a single 1M-token context window blob; beyond that, the + * upstream itself would reject the request before we even see it. + */ +import { test, expect } from "bun:test"; +import { buildSessionBlob } from "./session-blob"; +import { SessionTracker, type Message } from "./session"; + +function bigString(bytes: number): string { + // a-z repeating: predictable + JSON-safe (no escape blow-up) + const block = "abcdefghijklmnopqrstuvwxyz".repeat(40); // 1040 bytes + const n = Math.ceil(bytes / block.length); + return block.repeat(n).slice(0, bytes); +} + +test("buildSessionBlob handles a 1 MB user message verbatim", () => { + const userContent = bigString(1_000_000); + const reqText = JSON.stringify({ + model: "x", + messages: [{ role: "user", content: userContent }], + }); + const t0 = performance.now(); + const r = buildSessionBlob({ + session_id: "x", + course: "C", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: new TextEncoder().encode(reqText), + response_bytes: new TextEncoder().encode("{}"), + }); + const dt = performance.now() - t0; + + expect(r.blob_size).toBeGreaterThan(1_000_000); + // Round-trip the blob and verify the message survived byte-for-byte. + const parsed = JSON.parse(new TextDecoder().decode(r.blob_bytes)); + expect(parsed.request.messages[0].content).toBe(userContent); + // sha256 still matches (no silent truncation) + const expected = new Bun.CryptoHasher("sha256").update(r.blob_bytes).digest(); + expect(Buffer.from(r.blob_hash)).toEqual(Buffer.from(expected as Uint8Array)); + // Perf sanity: <500ms even at 1 MB. If this jumps to 5s+ on a refactor, + // someone introduced an O(N²). + expect(dt).toBeLessThan(500); +}); + +test("buildSessionBlob handles a 1 MB tools array verbatim", () => { + // 200 tools × ~8 KB each → comfortably >1 MB after JSON serialisation. + const tools = Array.from({ length: 200 }, (_, i) => ({ + type: "function", + function: { + name: `tool_${i}`, + description: bigString(8000), + parameters: { type: "object", properties: {} }, + }, + })); + const reqText = JSON.stringify({ + model: "x", + messages: [{ role: "user", content: "go" }], + tools, + }); + const r = buildSessionBlob({ + session_id: "x", + course: "C", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: new TextEncoder().encode(reqText), + response_bytes: new TextEncoder().encode("{}"), + }); + expect(r.blob_size).toBeGreaterThan(1_000_000); + const parsed = JSON.parse(new TextDecoder().decode(r.blob_bytes)); + expect(parsed.request.tools).toHaveLength(200); + expect(parsed.request.tools[199].function.name).toBe("tool_199"); +}); + +test("buildSessionBlob handles a 1 MB SSE-streamed response (kept as raw string)", () => { + // Synthesise SSE that doesn't parse as JSON. + const chunks: string[] = []; + let total = 0; + while (total < 1_000_000) { + const c = `data: {"choices":[{"delta":{"content":"${bigString(800)}"}}]}\n\n`; + chunks.push(c); + total += c.length; + } + chunks.push("data: [DONE]\n\n"); + const sse = chunks.join(""); + + const r = buildSessionBlob({ + session_id: "x", + course: "C", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: new TextEncoder().encode('{"model":"x","messages":[]}'), + response_bytes: new TextEncoder().encode(sse), + }); + const parsed = JSON.parse(new TextDecoder().decode(r.blob_bytes)); + expect(typeof parsed.response).toBe("string"); + expect(parsed.response.length).toBeGreaterThan(1_000_000); + expect(parsed.response.endsWith("[DONE]\n\n")).toBe(true); +}); + +test("SessionTracker prefix-extends a 1 MB conversation across two turns", () => { + // Each turn re-sends the full history (OpenAI protocol). + const big = bigString(500_000); // 500 KB per message + const turn1: Message[] = [{ role: "user", content: big }]; + const turn2: Message[] = [ + { role: "user", content: big }, + { role: "assistant", content: "ack" }, + { role: "user", content: "more" }, + ]; + + const tr = new SessionTracker(); + const t0 = performance.now(); + const r1 = tr.classify(turn1); + const r2 = tr.classify(turn2); + const dt = performance.now() - t0; + + expect(r1.is_new).toBe(true); + expect(r2.is_new).toBe(false); + expect(r2.session_id).toBe(r1.session_id); + expect(r2.turn_count).toBe(2); + // canonicalize + stable-stringify on a 500KB message is O(N) per turn. + // Two turns should be comfortably <1s. + expect(dt).toBeLessThan(1000); +}); + +test("SessionTracker handles 10 sessions × 1 MB each without quadratic blowup", () => { + // Adversarial: many large sessions kept in the LRU tracker simultaneously. + // classify() does an O(sessions) walk; per-session compare is O(msgs). + // With 10 sessions of 1 MB each, a 10th-classify shouldn't take >1s. + const tr = new SessionTracker(); + const t0 = performance.now(); + for (let i = 0; i < 10; i++) { + tr.classify([{ role: "user", content: `${i}:${bigString(100_000)}` }]); + } + // Now extend the FIRST session — forces a walk over all 10. + const r = tr.classify([ + { role: "user", content: `0:${bigString(100_000)}` }, + { role: "assistant", content: "ok" }, + { role: "user", content: "more" }, + ]); + const dt = performance.now() - t0; + + expect(r.is_new).toBe(false); + expect(r.turn_count).toBe(2); + expect(dt).toBeLessThan(2000); +}); diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts new file mode 100644 index 0000000..b626a93 --- /dev/null +++ b/llm-client/src/recording/session-blob.test.ts @@ -0,0 +1,261 @@ +import { test, expect } from "bun:test"; +import { buildSessionBlob } from "./session-blob"; + +function enc(s: string): Uint8Array { + return new TextEncoder().encode(s); +} +function dec(b: Uint8Array): string { + return new TextDecoder().decode(b); +} + +const reqText = + '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"},{"role":"user","content":"more"}]}'; +const respText = + '{"id":"chatcmpl-x","choices":[{"message":{"role":"assistant","content":"sure"}}]}'; + +test("router metadata is at blob root; the whole parsed request lives under `request`", () => { + const r = buildSessionBlob({ + session_id: "11111111-2222-3333-4444-555555555555", + course: "ECE4721J", + assignment: "hw1", + started_at: new Date("2026-05-13T10:00:00Z"), + latest_ts: new Date("2026-05-13T10:01:00Z"), + turn_count: 2, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc(reqText), + response_bytes: enc(respText), + }); + + const obj = JSON.parse(dec(r.blob_bytes)); + // Router-side metadata (NOT in the request body). + expect(obj.session_id).toBe("11111111-2222-3333-4444-555555555555"); + expect(obj.course).toBe("ECE4721J"); + expect(obj.turn_count).toBe(2); + expect(obj.upstream_status).toBe(200); + expect(obj.upstream).toEqual({ type: "openai" }); + + // The parsed request is the source of truth for what the model saw. + expect(obj.request.model).toBe("gpt-4o"); + expect(obj.request.messages).toHaveLength(3); + expect(obj.request.messages[2]).toEqual({ role: "user", content: "more" }); + + // The response, parsed if JSON, raw string if not. + expect(obj.response.choices[0].message.content).toBe("sure"); +}); + +test("ANY field on the request body is preserved verbatim (future-proof)", () => { + // Throw a kitchen sink at the router and verify nothing got dropped. + // Includes fields that don't exist today but might tomorrow. + const futureRequestText = JSON.stringify({ + model: "x", + messages: [{ role: "user", content: "hi" }], + temperature: 0.7, + top_p: 0.9, + max_tokens: 1024, + presence_penalty: 0.1, + frequency_penalty: 0.2, + seed: 42, + stop: ["END"], + response_format: { type: "json_object" }, + parallel_tool_calls: true, + reasoning_effort: "high", + // and a hypothetical future field + fancy_new_param_2027: { foo: "bar" }, + }); + const r = buildSessionBlob({ + session_id: "x", + course: "C", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc(futureRequestText), + response_bytes: enc("{}"), + }); + const obj = JSON.parse(dec(r.blob_bytes)); + expect(obj.request.temperature).toBe(0.7); + expect(obj.request.top_p).toBe(0.9); + expect(obj.request.max_tokens).toBe(1024); + expect(obj.request.presence_penalty).toBe(0.1); + expect(obj.request.frequency_penalty).toBe(0.2); + expect(obj.request.seed).toBe(42); + expect(obj.request.stop).toEqual(["END"]); + expect(obj.request.response_format).toEqual({ type: "json_object" }); + expect(obj.request.parallel_tool_calls).toBe(true); + expect(obj.request.reasoning_effort).toBe("high"); + expect(obj.request.fancy_new_param_2027).toEqual({ foo: "bar" }); +}); + +test("tools + tool_choice round-trip through `request`", () => { + const toolRequestText = JSON.stringify({ + model: "x", + messages: [{ role: "user", content: "list files" }], + tools: [ + { + type: "function", + function: { + name: "fs_read", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + }, + }, + }, + ], + tool_choice: "auto", + }); + const r = buildSessionBlob({ + session_id: "x", + course: "C", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc(toolRequestText), + response_bytes: enc("{}"), + }); + const obj = JSON.parse(dec(r.blob_bytes)); + expect(obj.request.tools).toHaveLength(1); + expect(obj.request.tools[0].function.name).toBe("fs_read"); + expect(obj.request.tool_choice).toBe("auto"); +}); + +test("blob is pretty-printed and the hash matches sha256(blob_bytes)", () => { + const r = buildSessionBlob({ + session_id: "abc", + course: "X", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc(reqText), + response_bytes: enc(respText), + }); + expect(dec(r.blob_bytes)).toContain('\n "session_id":'); + const expected = new Bun.CryptoHasher("sha256").update(r.blob_bytes).digest(); + expect(Buffer.from(r.blob_hash)).toEqual(Buffer.from(expected as Uint8Array)); + expect(r.blob_size).toBe(r.blob_bytes.byteLength); +}); + +test("streaming SSE response stays as a raw string under `response`", () => { + const sse = + 'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n' + + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n' + + "data: [DONE]\n\n"; + const r = buildSessionBlob({ + session_id: "x", + course: "Y", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc('{"model":"gpt-4o","stream":true,"messages":[]}'), + response_bytes: enc(sse), + }); + const obj = JSON.parse(dec(r.blob_bytes)); + expect(typeof obj.response).toBe("string"); + expect(obj.response).toBe(sse); +}); + +test("Responses API request and streamed response dump without schema conversion", () => { + const requestText = JSON.stringify({ + model: "gpt-5.3-codex", + instructions: "Follow the course policy.", + input: [ + { + role: "user", + content: [{ type: "input_text", text: "explain AIMD" }], + }, + ], + store: false, + stream: true, + }); + const sse = + 'event: response.output_text.delta\ndata: {"delta":"AIMD"}\n\n' + + 'event: response.completed\ndata: {"id":"resp_123"}\n\n'; + const r = buildSessionBlob({ + session_id: "11111111-2222-3333-4444-555555555555", + course: "ECE4721J", + assignment: "hw1", + started_at: new Date("2026-05-13T10:00:00Z"), + latest_ts: new Date("2026-05-13T10:01:00Z"), + turn_count: 1, + upstream_type: "codex", + upstream_status: 200, + request_bytes: enc(requestText), + response_bytes: enc(sse), + }); + + const obj = JSON.parse(dec(r.blob_bytes)); + expect(obj.request.model).toBe("gpt-5.3-codex"); + expect(obj.request.instructions).toBe("Follow the course policy."); + expect(obj.request.input[0].content[0].text).toBe("explain AIMD"); + expect(obj.request.store).toBe(false); + expect(obj.request.stream).toBe(true); + expect(typeof obj.response).toBe("string"); + expect(obj.response).toContain("response.output_text.delta"); +}); + +test("unparseable request bytes are kept verbatim under `request` (as a string)", () => { + const r = buildSessionBlob({ + session_id: "x", + course: "Y", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc("not json"), + response_bytes: enc(""), + }); + const obj = JSON.parse(dec(r.blob_bytes)); + // tryParseJSON returns the raw string for invalid JSON, so audit + // can still see what the client sent. + expect(obj.request).toBe("not json"); + expect(obj.response).toBe(""); +}); + +test("two consecutive turns of the same session produce strictly growing blob sizes", () => { + const turn1 = buildSessionBlob({ + session_id: "s1", + course: "X", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(0), + turn_count: 1, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc( + '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}', + ), + response_bytes: enc('{"choices":[{"message":{"content":"hello"}}]}'), + }); + const turn2 = buildSessionBlob({ + session_id: "s1", + course: "X", + assignment: "hw1", + started_at: new Date(0), + latest_ts: new Date(1), + turn_count: 2, + upstream_type: "openai", + upstream_status: 200, + request_bytes: enc( + '{"model":"gpt-4o","messages":[' + + '{"role":"user","content":"hi"},' + + '{"role":"assistant","content":"hello"},' + + '{"role":"user","content":"more"}]}', + ), + response_bytes: enc('{"choices":[{"message":{"content":"sure"}}]}'), + }); + expect(turn2.blob_size).toBeGreaterThan(turn1.blob_size); +}); diff --git a/llm-client/src/recording/session-blob.ts b/llm-client/src/recording/session-blob.ts new file mode 100644 index 0000000..69a3a1b --- /dev/null +++ b/llm-client/src/recording/session-blob.ts @@ -0,0 +1,68 @@ +import { decodeBytes, tryParseJSON } from "./capture"; + +export type SessionBlobInput = { + session_id: string; + course: string; + assignment: string; + started_at: Date; + latest_ts: Date; + turn_count: number; + upstream_type: string; + upstream_status: number; + request_bytes: Uint8Array; + response_bytes: Uint8Array; +}; + +export type SessionBlobResult = { + blob_bytes: Uint8Array; + blob_hash: Uint8Array; + blob_size: number; +}; + +/** + * Build the per-session blob file. Each new turn overwrites this file + * on jbox, so it always reflects the latest known state of the session. + * + * Schema (source of truth — anything the model saw is in `request`, + * anything it returned is in `response`): + * + * { + * session_id, course, started_at, latest_ts, turn_count, + * upstream: { type }, upstream_status, + * request: , + * response: + * } + * + * Consumers read `request.messages` or `request.input`, `request.tools`, + * `request.model`, `request.temperature`, etc. directly. We do NOT + * extract individual fields onto the blob root — that just means we'd + * have to extend the extractor every time the upstream protocol gains a + * parameter (response_format, parallel_tool_calls, reasoning_effort, …). + * Source of truth, single place. + */ +export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { + const reqText = decodeBytes(input.request_bytes); + const respText = decodeBytes(input.response_bytes); + const parsedReq = tryParseJSON(reqText); + const parsedResp = tryParseJSON(respText); + + const blob = { + session_id: input.session_id, + course: input.course, + assignment: input.assignment, + started_at: input.started_at.toISOString(), + latest_ts: input.latest_ts.toISOString(), + turn_count: input.turn_count, + upstream: { type: input.upstream_type }, + upstream_status: input.upstream_status, + request: parsedReq, + response: parsedResp, + }; + + const blob_bytes = new TextEncoder().encode(JSON.stringify(blob, null, 2)); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(blob_bytes); + const blob_hash = hasher.digest() as Uint8Array; + return { blob_bytes, blob_hash, blob_size: blob_bytes.byteLength }; +} diff --git a/llm-client/src/recording/session.test.ts b/llm-client/src/recording/session.test.ts new file mode 100644 index 0000000..f39abba --- /dev/null +++ b/llm-client/src/recording/session.test.ts @@ -0,0 +1,189 @@ +import { test, expect } from "bun:test"; +import { SessionTracker, type Message } from "./session"; + +function msg(role: string, content: string): Message { + return { role, content }; +} + +test("first request starts a new session with turn_count=1", () => { + const tr = new SessionTracker(); + const r = tr.classify([msg("user", "hi")]); + expect(r.is_new).toBe(true); + expect(r.turn_count).toBe(1); + expect(r.session_id).toMatch(/^[0-9a-f-]{36}$/); +}); + +test("request that strictly extends prior messages continues the same session", () => { + const tr = new SessionTracker(); + const a = tr.classify([msg("user", "hi")]); + const b = tr.classify([ + msg("user", "hi"), + msg("assistant", "hello"), + msg("user", "more"), + ]); + expect(b.session_id).toBe(a.session_id); + expect(b.is_new).toBe(false); + expect(b.turn_count).toBe(2); +}); + +test("a third extending turn keeps the session and increments turn_count", () => { + const tr = new SessionTracker(); + tr.classify([msg("user", "a")]); + tr.classify([msg("user", "a"), msg("assistant", "1"), msg("user", "b")]); + const third = tr.classify([ + msg("user", "a"), + msg("assistant", "1"), + msg("user", "b"), + msg("assistant", "2"), + msg("user", "c"), + ]); + expect(third.is_new).toBe(false); + expect(third.turn_count).toBe(3); +}); + +test("a different first message starts a new session", () => { + const tr = new SessionTracker(); + const a = tr.classify([msg("user", "hi")]); + const b = tr.classify([msg("user", "different")]); + expect(b.session_id).not.toBe(a.session_id); + expect(b.is_new).toBe(true); +}); + +test("a shorter messages array doesn't match a prior longer session — starts new", () => { + const tr = new SessionTracker(); + const long = tr.classify([ + msg("user", "hi"), + msg("assistant", "hello"), + msg("user", "more"), + ]); + const shorter = tr.classify([msg("user", "hi")]); + expect(shorter.session_id).not.toBe(long.session_id); + expect(shorter.is_new).toBe(true); +}); + +test("two coexisting sessions (different prefixes) both continue correctly", () => { + const tr = new SessionTracker(); + const a1 = tr.classify([msg("user", "AAA")]); + const b1 = tr.classify([msg("user", "BBB")]); + expect(a1.session_id).not.toBe(b1.session_id); + + const a2 = tr.classify([ + msg("user", "AAA"), + msg("assistant", "x"), + msg("user", "more A"), + ]); + const b2 = tr.classify([ + msg("user", "BBB"), + msg("assistant", "y"), + msg("user", "more B"), + ]); + expect(a2.session_id).toBe(a1.session_id); + expect(b2.session_id).toBe(b1.session_id); + expect(a2.turn_count).toBe(2); + expect(b2.turn_count).toBe(2); +}); + +test("LRU evicts the least-recently-touched session when capacity is exceeded", () => { + const tr = new SessionTracker(2); + tr.classify([msg("user", "session-A")]); + tr.classify([msg("user", "session-B")]); + tr.classify([msg("user", "session-C")]); // evicts A + const backToA = tr.classify([ + msg("user", "session-A"), + msg("assistant", "x"), + msg("user", "y"), + ]); + // A was evicted, so this looks like a brand-new session. + expect(backToA.is_new).toBe(true); +}); + +test("identical messages array (no growth) is treated as a new session, not a continuation", () => { + const tr = new SessionTracker(); + const a = tr.classify([msg("user", "hi")]); + const b = tr.classify([msg("user", "hi")]); + // Same content but no extension — could be a retry or a fresh ask. Treat as new. + expect(b.session_id).not.toBe(a.session_id); +}); + +test("empty messages array produces a new session each time", () => { + const tr = new SessionTracker(); + const a = tr.classify([]); + const b = tr.classify([]); + expect(a.is_new).toBe(true); + expect(b.is_new).toBe(true); + expect(a.session_id).not.toBe(b.session_id); +}); + +test("key order does not matter — reordered keys on the same message extend the session", () => { + // Agents sometimes round-trip messages through their own serializer + // and re-emit them with a different key order. If we did naive + // JSON.stringify equality, the second turn would look like a new + // session and we'd silently lose O(N) blob storage. + const tr = new SessionTracker(); + const first = tr.classify([ + { role: "assistant", content: "x", name: "bot" } as Message, + ]); + const second = tr.classify([ + { name: "bot", content: "x", role: "assistant" } as Message, // same fields, reordered + { role: "user", content: "continue" } as Message, + ]); + expect(second.is_new).toBe(false); + expect(second.session_id).toBe(first.session_id); + expect(second.turn_count).toBe(2); +}); + +test("deeply nested key reorder (tool_calls etc.) still matches", () => { + const tr = new SessionTracker(); + const first = tr.classify([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "1", + type: "function", + function: { name: "ls", arguments: "{}" }, + }, + ], + } as Message, + ]); + const second = tr.classify([ + { + tool_calls: [ + { + function: { arguments: "{}", name: "ls" }, + type: "function", + id: "1", + }, + ], + content: null, + role: "assistant", + } as Message, + { role: "tool", content: "a.txt", tool_call_id: "1" } as Message, + ]); + expect(second.is_new).toBe(false); + expect(second.session_id).toBe(first.session_id); +}); + +test("messages with structured content (tool_calls etc.) compare correctly", () => { + const tr = new SessionTracker(); + const a = tr.classify([ + { role: "user", content: "list files" } as Message, + { + role: "assistant", + content: null, + tool_calls: [{ id: "1", function: { name: "ls", arguments: "{}" } }], + } as Message, + ]); + const b = tr.classify([ + { role: "user", content: "list files" } as Message, + { + role: "assistant", + content: null, + tool_calls: [{ id: "1", function: { name: "ls", arguments: "{}" } }], + } as Message, + { role: "tool", content: "a.txt b.txt", tool_call_id: "1" } as Message, + ]); + expect(b.session_id).toBe(a.session_id); + expect(b.is_new).toBe(false); +}); diff --git a/llm-client/src/recording/session.ts b/llm-client/src/recording/session.ts new file mode 100644 index 0000000..487ac32 --- /dev/null +++ b/llm-client/src/recording/session.ts @@ -0,0 +1,124 @@ +/** + * Recognises whether a chat request continues a prior agent session by + * checking whether its `messages` array is a strict extension of the + * tip we last saw. Lets us store one blob per session (overwriting on + * each turn) instead of one blob per HTTP call — turns O(N²) total + * bytes into O(N). + * + * Identity is in-memory only: a router restart starts fresh sessions. + * That's acceptable because we never need to *resume* — we just need to + * keep the per-session blob keyed by a stable id within one run. + */ +import { randomUUID } from "node:crypto"; + +export type Message = { + role: string; + content: unknown; + [extra: string]: unknown; +}; + +export type SessionClassification = { + session_id: string; + turn_count: number; + is_new: boolean; + started_at: Date; +}; + +type SessionState = { + id: string; + /** Last `messages` array we saw on a request that classified to this session. */ + tip: Message[]; + turn_count: number; + started_at: Date; + /** Monotonic counter used for LRU eviction (not wall clock — wall clock can tie). */ + last_seen_seq: number; +}; + +export class SessionTracker { + private sessions: Map = new Map(); + private readonly capacity: number; + private seq = 0; + + constructor(capacity = 32) { + this.capacity = capacity; + } + + /** + * Classify a chat request's `messages` array as either continuing an + * active session or starting a new one. + */ + classify(messages: Message[], now: Date = new Date()): SessionClassification { + // Search most-recently-touched sessions first. + const ordered = [...this.sessions.values()].sort( + (a, b) => b.last_seen_seq - a.last_seen_seq, + ); + for (const s of ordered) { + if (isPrefixOf(s.tip, messages)) { + s.tip = messages; + s.turn_count += 1; + s.last_seen_seq = ++this.seq; + return { + session_id: s.id, + turn_count: s.turn_count, + is_new: false, + started_at: s.started_at, + }; + } + } + + if (this.sessions.size >= this.capacity) this.evictOldest(); + const id = randomUUID(); + const state: SessionState = { + id, + tip: messages, + turn_count: 1, + started_at: now, + last_seen_seq: ++this.seq, + }; + this.sessions.set(id, state); + return { session_id: id, turn_count: 1, is_new: true, started_at: now }; + } + + private evictOldest(): void { + let oldest: SessionState | undefined; + for (const s of this.sessions.values()) { + if (!oldest || s.last_seen_seq < oldest.last_seen_seq) oldest = s; + } + if (oldest) this.sessions.delete(oldest.id); + } +} + +/** + * True iff `prior` is a strict prefix of `next` (same length is NOT a prefix — + * a turn without new content shouldn't be confused with a continuation). + */ +function isPrefixOf(prior: Message[], next: Message[]): boolean { + if (prior.length === 0 || prior.length >= next.length) return false; + for (let i = 0; i < prior.length; i++) { + if (!messagesEqual(prior[i]!, next[i]!)) return false; + } + return true; +} + +function messagesEqual(a: Message, b: Message): boolean { + // Stable key ordering matters. Some agent clients round-trip messages + // through their own serializers and re-emit with a different key order; + // a naive JSON.stringify would treat that as a new session and silently + // regress us to O(N²) blob storage. + return canonicalStringify(a) === canonicalStringify(b); +} + +function canonicalStringify(v: unknown): string { + return JSON.stringify(canonicalize(v)); +} + +function canonicalize(v: unknown): unknown { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(canonicalize); + const src = v as Record; + // Object.create(null) — not {} — so a "__proto__" key writes a regular + // property instead of mutating the prototype chain. + const out = Object.create(null) as Record; + for (const k of Object.keys(src).sort()) out[k] = canonicalize(src[k]); + return out; +} diff --git a/llm-client/src/util.test.ts b/llm-client/src/util.test.ts new file mode 100644 index 0000000..7a5922a --- /dev/null +++ b/llm-client/src/util.test.ts @@ -0,0 +1,60 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeAtomic, redactToken } from "./util"; + +const tmpDirs: string[] = []; +function freshDir(): string { + const d = mkdtempSync(join(tmpdir(), "aimdware-atomic-")); + tmpDirs.push(d); + return d; +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); +}); + +test("writeAtomic writes the full content to the target path", async () => { + const dir = freshDir(); + const target = join(dir, "file.json"); + await writeAtomic(target, new TextEncoder().encode("hello")); + expect(readFileSync(target, "utf-8")).toBe("hello"); +}); + +test("writeAtomic leaves no temp files in the directory on success", async () => { + const dir = freshDir(); + await writeAtomic(join(dir, "a.json"), new TextEncoder().encode("x")); + await writeAtomic(join(dir, "b.json"), new TextEncoder().encode("y")); + const files = readdirSync(dir); + expect(files.sort()).toEqual(["a.json", "b.json"]); +}); + +test("writeAtomic overwrites an existing file", async () => { + const dir = freshDir(); + const target = join(dir, "file.json"); + await writeAtomic(target, new TextEncoder().encode("first")); + await writeAtomic(target, new TextEncoder().encode("second")); + expect(readFileSync(target, "utf-8")).toBe("second"); +}); + +test("writeAtomic on a non-existent directory throws (does not silently mkdir)", async () => { + await expect( + writeAtomic("/nonexistent/dir/x.json", new TextEncoder().encode("x")), + ).rejects.toThrow(); +}); + +test("redactToken keeps only the 8-char prefix; full plaintext never appears", () => { + const plaintext = "st_K9aBxYz1234567890abcdefghijklmnopqrstuvwxyz"; + const redacted = redactToken(plaintext); + expect(redacted.startsWith("st_K9aBx")).toBe(true); + expect(redacted).not.toContain(plaintext.slice(8)); + expect(redacted).not.toContain(plaintext); +}); + +test("redactToken collapses short / empty values without leaking", () => { + expect(redactToken("")).toBe("(unset)"); + expect(redactToken(undefined)).toBe("(unset)"); + expect(redactToken(null)).toBe("(unset)"); + expect(redactToken("short")).toBe("***"); +}); diff --git a/llm-client/src/util.ts b/llm-client/src/util.ts new file mode 100644 index 0000000..b9d280d --- /dev/null +++ b/llm-client/src/util.ts @@ -0,0 +1,87 @@ +import { rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Where the router stages a session's blob on disk before sync. + * + * Returned path is identical to what `main.ts`'s onCapture writes, + * what `buildSyncStage` reads, and what `eviction.ts` unlinks — exactly + * three call sites that must agree on the layout, so it lives here. + */ +export function sessionBlobPath(cacheDir: string, session_id: string): string { + return join(cacheDir, "records", `${session_id}.json`); +} + +/** + * Write data to `path` atomically: write to a sibling temp file, then rename. + * + * On POSIX, rename is atomic on the same filesystem. A crash mid-write leaves + * a stray temp file (cleaned up next sweep) but never a corrupted target. + * + * Caller must ensure the destination directory exists. Pass `mode` to set the + * file's permission bits: the temp file is *created* with that mode (not + * chmod'd after), so a credential never exists on disk with default, + * world-readable perms even briefly. + */ +export async function writeAtomic( + path: string, + data: Uint8Array, + opts?: { mode?: number }, +): Promise { + const tmp = `${path}.tmp.${crypto.randomUUID()}`; + if (opts?.mode !== undefined) { + await writeFile(tmp, data, { mode: opts.mode }); + } else { + await Bun.write(tmp, data); + } + await rename(tmp, path); +} + +export function bytesToHex(b: Uint8Array): string { + return Buffer.from(b).toString("hex"); +} + +/** + * Mask a credential for human display in logs / status pages. + * + * Short tokens collapse to "***". Long tokens keep the leading 8 chars + * (the human "prefix" — matches what the admin script + backend show) + * plus a "..." suffix. + * + * NEVER include the full plaintext in any printed string. + */ +export function redactToken(token: string | undefined | null): string { + if (!token) return "(unset)"; + if (token.length <= 12) return "***"; + return `${token.slice(0, 8)}…`; +} + +/** + * Sleep that resolves immediately when stop() is called. Used by long-poll + * worker loops so SIGTERM-driven shutdown doesn't hang on a half-finished + * 30-minute interval. + */ +export class StoppableSleep { + private stopped = false; + private wake: (() => void) | null = null; + + sleep(ms: number): Promise { + if (this.stopped) return Promise.resolve(); + return new Promise((resolve) => { + const t = setTimeout(() => { + this.wake = null; + resolve(); + }, ms); + this.wake = () => { + clearTimeout(t); + this.wake = null; + resolve(); + }; + }); + } + + stop(): void { + this.stopped = true; + this.wake?.(); + } +} diff --git a/llm-client/tsconfig.json b/llm-client/tsconfig.json new file mode 100644 index 0000000..53ba375 --- /dev/null +++ b/llm-client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "allowJs": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/test-functional/.current b/test-functional/.current new file mode 120000 index 0000000..7931211 --- /dev/null +++ b/test-functional/.current @@ -0,0 +1 @@ +/Users/zhouzhaojiacheng/personal/courses/ta/router/aimdware/test-functional/runs/20260514-141018 \ No newline at end of file diff --git a/test-functional/02-image.png b/test-functional/02-image.png new file mode 100644 index 0000000..909c66d Binary files /dev/null and b/test-functional/02-image.png differ diff --git a/test-functional/README.md b/test-functional/README.md new file mode 100644 index 0000000..85784f1 --- /dev/null +++ b/test-functional/README.md @@ -0,0 +1,61 @@ +# Functional tests against real SJTU upstream + +End-to-end functional tests that drive the aimdware router with **real** +LLM calls through the SJTU OpenAI-compatible gateway, then inspect what +landed in the backend DB + jbox. + +## Stack + +``` +opencode → 127.0.0.1:$ROUTER_PORT/v1 (aimdware-router) + ↓ records to outbox + ↓ blob → real Tbox (admin:admin @ 127.0.0.1:50471) + ↓ metadata → backend (on-disk sqlite under runs/) + real upstream: https://models.sjtu.edu.cn/api/v1 +``` + +Constraints: +- 10 req/min, 100k tok/min, 1B tok/week. +- Pace tests serially; don't run two at once. + +## Models + +| name in config | call name | +|--------------------------|-------------------| +| DeepSeek V3.2 (chat) | `deepseek-chat` | +| DeepSeek V3.2 (reason) | `deepseek-reasoner` | +| MiniMax-M2.7 | `minimax` | +| GLM-5.1 | `glm` | +| Qwen3.5-27B | `qwen` | + +## Layout + +``` +test-functional/ + bringup.sh starts backend + router, exports BACKEND_PORT / ROUTER_PORT + teardown.sh kills bg + best-effort Tbox cleanup + opencode.json project-local opencode config: provider → router + prompts/ one prompt file per test (verbatim copy of what we send) + runs/ per-test artifacts: backend db, router log, captured blob hash + (gitignored) +``` + +## Running a test + +``` +source ./test-functional/bringup.sh # exports envs +cat ./test-functional/prompts/01-small-task.md # see the prompt +opencode run --model aimdware/deepseek-chat "..." # invoke +./test-functional/inspect.sh # dump DB + jbox blob +./test-functional/teardown.sh +``` + +## What each test exercises + +| # | Prompt file | Tests | +|---|-------------------------------|-------| +| 1 | `01-small-task.md` | Single-turn capture; session of 1 | +| 2 | `02-multimodal.md` | Vision request; what the blob captures | +| 3 | `03-skill.md` | Skill invocation; tool-message turns in blob | +| 4 | `04-mcp.md` | MCP tool call; tool-result turns | +| 5 | `05-compression.md` | Long session; opencode compression behavior vs SessionTracker prefix detection | diff --git a/test-functional/bringup.sh b/test-functional/bringup.sh new file mode 100755 index 0000000..0bab67c --- /dev/null +++ b/test-functional/bringup.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Source this file: source ./test-functional/bringup.sh +# Exports BACKEND_PORT, ROUTER_PORT, COURSE, TOKEN, WORK so the caller +# can run opencode + ./test-functional/inspect.sh. +set -euo pipefail + +# Works under bash (BASH_SOURCE) and zsh (where $0 is the sourced path +# during `source`). Falls back to $PWD if both are unset. +_self="${BASH_SOURCE[0]:-${(%):-%x}}" +_self="${_self:-$0}" +REPO_ROOT="$(cd "$(dirname "$_self")/.." && pwd)" +export WORK="$REPO_ROOT/test-functional/runs/$(date +%Y%m%d-%H%M%S)" +mkdir -p "$WORK" + +# Tbox: already running locally (admin:admin @ 127.0.0.1:50471). +export AIMDWARE_TBOX_URL="${AIMDWARE_TBOX_URL:-http://127.0.0.1:50471}" +export AIMDWARE_TBOX_USER="${AIMDWARE_TBOX_USER:-admin}" +export AIMDWARE_TBOX_PASS="${AIMDWARE_TBOX_PASS:-admin}" + +# Upstream: SJTU OpenAI-compatible gateway. +UPSTREAM_BASE="${SJTU_UPSTREAM_BASE:-https://models.sjtu.edu.cn/api/v1}" +UPSTREAM_KEY="${SJTU_UPSTREAM_KEY:?set SJTU_UPSTREAM_KEY env var first}" + +export COURSE="FUNC-$(date +%s)" +export BACKEND_PORT="$((20000 + RANDOM % 30000))" +export ROUTER_PORT="$((20000 + RANDOM % 30000))" +export AIMDWARE_DATABASE_URL="sqlite:///$WORK/aimdware.db" +export AIMDWARE_ADMIN_SECRET="func-admin-secret" + +echo "--- workdir: $WORK ---" +echo " backend: :$BACKEND_PORT" +echo " router: :$ROUTER_PORT" +echo " course: $COURSE" +echo " upstream: $UPSTREAM_BASE" +echo " tbox: $AIMDWARE_TBOX_URL" + +# Probe Tbox. +if ! curl -sS -u "$AIMDWARE_TBOX_USER:$AIMDWARE_TBOX_PASS" -o /dev/null -w "%{http_code}\n" \ + "$AIMDWARE_TBOX_URL/" | grep -qE '^(200|207|401)$'; then + echo "FAIL: Tbox at $AIMDWARE_TBOX_URL is not responding" >&2 + return 1 2>/dev/null || exit 1 +fi + +# Apply alembic migrations on the fresh DB. +(cd "$REPO_ROOT/backend" && uv run alembic upgrade head >"$WORK/alembic.log" 2>&1) + +# Start backend. +(cd "$REPO_ROOT/backend" && \ + uv run uvicorn aimdware_backend.main:app --port "$BACKEND_PORT" --log-level warning \ + ) >"$WORK/backend.log" 2>&1 & +echo $! >"$WORK/backend.pid" + +for _ in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$BACKEND_PORT/ingest/health" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# Seed user + course + token. +export E2E_PLAINTEXT="st_FUNC_TEST_$(date +%s)" +TOKEN="$(cd "$REPO_ROOT/backend" && E2E_COURSE="$COURSE" \ + uv run python scripts/seed_for_e2e.py)" +export TOKEN +echo "--- token issued: ${TOKEN:0:8}… ---" + +# Write router config. +cat >"$WORK/aimdware.yaml" <"$WORK/router.log" 2>&1 & +echo $! >"$WORK/router.pid" + +for _ in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$ROUTER_PORT/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# Project-local opencode.json so `opencode run` uses our router. +cat >"$REPO_ROOT/test-functional/opencode.json" <"$WORK/env.sh" </dev/null | head -1)" +fi +DB="$WORK/aimdware.db" +[ -f "$DB" ] || { echo "no aimdware.db at $DB"; exit 1; } + +echo "=== context_records ===" +sqlite3 "$DB" -header -column </dev/null | head -c 40 | tr '\n' ' ') + usr0=$(jq -r '.request.messages | map(select(.role == "user")) | .[0].content // ""' "$f" 2>/dev/null | head -c 40 | tr '\n' ' ') + printf '%s msgs=%2d sess=%s sys="%s…" usr="%s…"\n' "$ts" "$msgs" "$sid" "$sys0" "$usr0" +done | sort + +# If a session_id was passed in, also fetch the jbox blob. +SID="${1:-}" +if [ -z "$SID" ]; then + SID="$(sqlite3 "$DB" \ + "SELECT session_id FROM context_records ORDER BY ts DESC LIMIT 1;" 2>/dev/null)" +fi +SID_FMT="${SID:0:8}-${SID:8:4}-${SID:12:4}-${SID:16:4}-${SID:20:12}" +[ -n "$SID" ] && [ -n "${COURSE:-}" ] && { + echo + echo "=== /admin/session/$SID_FMT/payload ===" + curl -sS -H "Authorization: Bearer ${AIMDWARE_ADMIN_SECRET:-func-admin-secret}" \ + "http://127.0.0.1:${BACKEND_PORT}/admin/session/$SID_FMT/payload" \ + | python3 -m json.tool 2>/dev/null \ + | head -40 +} diff --git a/test-functional/mcp-sandbox/HELLO.txt b/test-functional/mcp-sandbox/HELLO.txt new file mode 100644 index 0000000..0e39fd8 --- /dev/null +++ b/test-functional/mcp-sandbox/HELLO.txt @@ -0,0 +1 @@ +the magic phrase is: PURPLE-HORSE-42 diff --git a/test-functional/prompts/01-small-task.md b/test-functional/prompts/01-small-task.md new file mode 100644 index 0000000..73754f5 --- /dev/null +++ b/test-functional/prompts/01-small-task.md @@ -0,0 +1,37 @@ +# Test 1 — agent executes a small task + +## Goal +Single-turn capture: prove that one chat-completion through the router +lands as exactly **one ContextRecord (turn_count=1)** in the backend and +**one blob file** in jbox under `aimdware/$COURSE/`, with `verified=true`. + +## Model +`aimdware/deepseek-chat` (cheapest, fastest on this gateway). + +## Prompt verbatim + +``` +Write a Python one-liner that reverses a string. Just the code, no +explanation, no markdown fences. +``` + +## How to run + +```bash +source ./test-functional/bringup.sh +cd test-functional # so opencode picks up our local opencode.json +opencode run --model aimdware/deepseek-chat \ + "Write a Python one-liner that reverses a string. Just the code, no explanation, no markdown fences." +cd .. +sleep 4 +./test-functional/inspect.sh +./test-functional/teardown.sh +``` + +## Expected + +- 1 row in `context_records`, `blob_status=uploaded`, `turn_count=1` +- 1 file under `aimdware/$COURSE/` on jbox +- `/admin/session//payload` returns `verified: true`, with + `payload.messages` containing the user prompt and `payload.latest_response` + containing the assistant reply (`s[::-1]`). diff --git a/test-functional/prompts/02-multimodal.md b/test-functional/prompts/02-multimodal.md new file mode 100644 index 0000000..5322db2 --- /dev/null +++ b/test-functional/prompts/02-multimodal.md @@ -0,0 +1,79 @@ +# Test 2 — multimodal input/output + +## Goal +Drive an image-bearing request **through opencode + Sisyphus through +the router** and observe: + +1. Does the router faithfully proxy OpenAI's multimodal `content` + schema (array of `{type:"text"|"image_url", ...}` parts)? +2. Does the captured blob preserve the image (base64 or url-pointer) + exactly as sent, so a TT can verify what the student showed the LLM? +3. Does Sisyphus's orchestrator preserve the image part across its + sub-agent introspection turns, or does it strip / re-serialize it? + +## Model choice + +Probed all 5 SJTU models with a base64-inlined PNG: + +| model | result | +|-----------|----------------------------------------------------------| +| deepseek-chat / deepseek-reasoner | not multimodal (not even tried) | +| minimax | `litellm.BadRequestError: ... is not a multimodal model` | +| glm | `Hosted_vllmException - Internal Server Error` (5xx) | +| **qwen** | ✓ described the transparent PNG correctly | + +Use `aimdware/qwen`. + +The test image is `02-image.png` — a tiny 1×1 transparent PNG we +generate at runtime, base64-inlined into the request. Small enough to +not bloat blobs; presence is what we're checking, not content. + +## Prompt verbatim + +``` +I'm attaching a 1×1 transparent PNG. Describe what you see in it +in one sentence. If the image is empty or transparent, say so. +``` + +## How to run + +```bash +source ./test-functional/runs/$LATEST_RUN/env.sh +cd test-functional + +# Generate the test PNG (1×1 transparent, ~70 bytes) +python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='))" > 02-image.png + +opencode run --model aimdware/glm \ + -i 02-image.png \ + "I'm attaching a 1x1 transparent PNG. Describe what you see in it in one sentence. If the image is empty or transparent, say so." +cd .. + +sleep 4 +./test-functional/inspect.sh +``` + +## What to record + +For each captured blob, check `messages[i].content`: + +- If `content` is a string → text-only request (Sisyphus stripped the + image or the upstream collapsed it) +- If `content` is an array of parts → multimodal preserved. Look for + `type: "image_url"` entries and verify the base64 data survives + intact (length + first/last 20 chars). + +Save: +- `runs//02-findings.md` — the analysis +- Tag the blob that contained the actual image (if any) for visual + inspection in jbox. + +## Failure modes to flag + +- Router crashes on array `content` (would mean our JSON.parse path + doesn't tolerate arrays — but we already test that in + `session-blob.test.ts`, so this should pass) +- GLM rejects the request entirely (likely if SJTU's "glm" alias + isn't the vision variant) +- Sisyphus base64-decodes and re-encodes the image, mutating it + (we'd see `image_url` base64 differ across turns) diff --git a/test-functional/prompts/03-skill.md b/test-functional/prompts/03-skill.md new file mode 100644 index 0000000..2d3c9e2 --- /dev/null +++ b/test-functional/prompts/03-skill.md @@ -0,0 +1,53 @@ +# Test 3 — agent invokes a skill + +## Goal +opencode "skills" are markdown files the agent reads as part of its +system prompt. When the agent uses one, it shows up in the request as +extra system / assistant turns. We want to verify that the router's +SessionTracker: + +1. Treats the multi-turn dialogue (system + skill-bound user + assistant) + as **ONE session**, not multiple. +2. The jbox blob's final `messages` array contains the full conversation + including the skill content the agent loaded. + +## Setup + +Create a one-off skill under `test-functional/skills/string-tools/SKILL.md` +that defines a single trivial procedure. opencode's project-local +config discovers skills under `./skills/`. + +## Prompt verbatim + +``` +Use the string-tools skill to reverse this string for me: "elephant". +After the reversal, count the unique consonants in the result and tell +me the number. +``` + +## Model + +`aimdware/deepseek-chat` + +## How to run + +```bash +source ./test-functional/bringup.sh +cd test-functional +opencode run --model aimdware/deepseek-chat \ + "Use the string-tools skill to reverse this string for me: \"elephant\". After the reversal, count the unique consonants in the result and tell me the number." +cd .. +sleep 8 # multi-turn agent loop — give it room +./test-functional/inspect.sh +``` + +## Expected + +- Backend: **≥2 records**, all sharing one `session_id`. (Agent + typically does: turn 1 = first model call, turn 2 = post-skill model + call.) +- jbox: **exactly one** blob file for that session. +- The blob's final `messages` array contains the system prompt with + skill content, the user request, and any intermediate assistant + + tool messages. +- `verified=true` on the latest record. diff --git a/test-functional/prompts/04-mcp.md b/test-functional/prompts/04-mcp.md new file mode 100644 index 0000000..d2d69ee --- /dev/null +++ b/test-functional/prompts/04-mcp.md @@ -0,0 +1,69 @@ +# Test 4 — agent calls an MCP tool + +## Goal +Verify the router captures **tool_calls + tool messages** that come +from an MCP-backed tool. Same session-merging property as test 3: many +HTTP turns, one jbox blob. + +## Setup + +Use a minimal local MCP server — the official `@modelcontextprotocol/server-filesystem` +serving a single read-only temp dir we seed with one known file. opencode's +project-local config wires it in. + +``` +test-functional/mcp-sandbox/HELLO.txt ← single seed file +``` + +opencode config snippet (added to test-functional/opencode.json by setup): +```jsonc +"mcp": { + "fs": { + "type": "local", + "command": ["bunx", "-y", "@modelcontextprotocol/server-filesystem", ""] + } +} +``` + +## Prompt verbatim + +``` +There is a single file inside the MCP `fs` server's sandbox. +Use the filesystem MCP tools to read it and tell me its contents, +verbatim. Don't paraphrase. +``` + +## Model + +`aimdware/deepseek-chat` + +## How to run + +```bash +source ./test-functional/bringup.sh +mkdir -p test-functional/mcp-sandbox +echo "hello from MCP land" > test-functional/mcp-sandbox/HELLO.txt +cd test-functional +opencode run --model aimdware/deepseek-chat \ + "There is a single file inside the MCP fs server's sandbox. Use the filesystem MCP tools to read it and tell me its contents, verbatim. Don't paraphrase." +cd .. +sleep 12 # tool round-trips eat time +./test-functional/inspect.sh +``` + +## Expected + +- Backend: ≥3 records, all sharing one session_id (typical: list → + read → respond). +- jbox blob's `messages` array contains: + - `role: assistant` turn with `tool_calls` + - `role: tool` turn with the file contents + - final `role: assistant` reply +- `verified=true`. + +## What to flag + +- If the tool round-trips are split across **different** session_ids, + the SessionTracker prefix check broke. This would mean some tool + framework is mutating earlier messages between turns rather than + appending. diff --git a/test-functional/prompts/05-compression.md b/test-functional/prompts/05-compression.md new file mode 100644 index 0000000..80406bc --- /dev/null +++ b/test-functional/prompts/05-compression.md @@ -0,0 +1,86 @@ +# Test 5 — context compression vs SessionTracker + +## Goal +opencode (like Claude Code) summarises older turns when the conversation +approaches the model's context window. **This rewrites earlier messages** +— they're replaced by a short summary string. + +Our `SessionTracker.classify` requires the next request's `messages` +to be a **strict prefix-extension** of the prior tip. After compression, +the prefix changes — so: + +- BEFORE compression: turns 1..N share one session_id, one jbox file +- AT compression: turn N+1's `messages` is **NOT** an extension of turn + N's → SessionTracker classifies it as a **new session** → new + session_id, new jbox file + +That's the **expected** behavior. This test documents it explicitly so +TT folks understand why one logical agent run can produce 2+ jbox files +when it's long. + +## Prompt verbatim + +``` +Let's brainstorm a long, exhaustive, exploratory list. I want 30 distinct +ideas for a CS undergraduate capstone project at SJTU, each with 3 lines +of detail: (a) one-line description, (b) the hardest sub-problem, (c) +which course in the SJTU JI curriculum it builds on most naturally. +Number them 1 through 30. After ideas 15, give me a brief mid-point +summary of common themes. After idea 30, give a final synthesis. Be +verbose; quality beats brevity here. +``` + +This is engineered to be long enough that opencode may compress +mid-stream, especially if the agent stalls and we follow up. If the +single response doesn't trigger compression, send a follow-up: + +``` +Now expand idea #7 with three potential thesis-supervisor candidates +and what each would want to see in the proposal. +``` + +…and continue with two more follow-ups until compression visibly fires +(opencode logs it; we can also tell by the `messages` shrinking in the +captured blob across turns). + +## Model + +`aimdware/deepseek-reasoner` (longer answers + bigger context budget, +better chance of triggering compression). + +## How to run + +```bash +source ./test-functional/bringup.sh +cd test-functional +opencode run --model aimdware/deepseek-reasoner "" +# follow-ups in the same session: use opencode's --continue / session id +cd .. +./test-functional/inspect.sh +``` + +## Expected (assuming compression fires) + +- Backend: turns 1..K share `session_id_A`; turns K+1..N share + `session_id_B`. Both blobs in jbox. +- The first turn after compression has a much **shorter** `messages` + array than the last turn before. That's the signal. + +## Expected (assuming compression does NOT fire) + +- Single session_id across all turns. One jbox file. blob_size grows + monotonically with `turn_count`. + +## What to record + +For each transition between adjacent records: + +``` +ts turn_count session_id msg_count blob_size +... 1 A 1 567 +... 2 A 3 821 +... 3 A 5 1124 +... 4 B 7 740 ← compression! +``` + +Save this table into `runs//05-compression-trace.md`. diff --git a/test-functional/results/00-records.txt b/test-functional/results/00-records.txt new file mode 100644 index 0000000..3cf32fa --- /dev/null +++ b/test-functional/results/00-records.txt @@ -0,0 +1,52 @@ +rec sess turn_count blob_size ts +-------- -------- ---------- --------- -------------------------- +9bcec2f6 1f19b0a7 1 1400 2026-05-14 02:59:29.892000 +f953a8b2 a845ab43 1 4802 2026-05-14 02:59:41.237000 +9b67a8fc 88ab21af 1 43470 2026-05-14 02:59:45.046000 +de98988e 6fb62e9a 1 56600 2026-05-14 02:59:45.946000 +b83aa653 8f85ed3e 1 95895 2026-05-14 03:00:14.595000 +4988d10b ebe03dde 1 48991 2026-05-14 03:00:49.270000 +4e02e16e 67871a0f 1 92431 2026-05-14 03:00:50.193000 +4b7b416f db8689b1 1 46629 2026-05-14 03:01:23.142000 +9fd4c0a9 29f1e812 1 86521 2026-05-14 03:01:25.190000 +708dbf00 e26c3ee8 1 46960 2026-05-14 03:01:59.992000 +abe59c8c b72edd48 1 88364 2026-05-14 03:02:01.666000 +4f0dd91f d3e683fd 1 46960 2026-05-14 03:02:31.565000 +43c00654 e72eb897 1 91911 2026-05-14 03:02:32.451000 +91b3f1d2 845db7f5 1 46976 2026-05-14 03:03:04.362000 +ef598bfb ec798070 1 100448 2026-05-14 03:03:05.392000 +3b266488 c6eb2fbe 1 47428 2026-05-14 03:04:07.494000 +769f0243 31af4387 1 104626 2026-05-14 03:04:08.724000 +f379a295 ff5ce0d1 1 964 2026-05-14 03:14:43.443000 +cc4feb35 f18ac4c6 1 964 2026-05-14 03:14:48.270000 +023dfa25 a4c33d05 1 1720 2026-05-14 03:14:58.749000 +1e907a79 c6afaaa2 1 1011 2026-05-14 03:15:06.939000 +5467f946 e12a0fce 1 5139 2026-05-14 03:16:02.206000 +db130004 55e5a81a 1 46577 2026-05-14 03:16:14.931000 +5940c1b3 a469f1f6 1 5123 2026-05-14 03:19:22.618000 +30770d1a 3ce9056e 1 43871 2026-05-14 03:19:26.241000 +438a9481 9a7bd734 1 84400 2026-05-14 03:19:27.234000 +b95c325c ad8acc80 1 40854 2026-05-14 03:19:58.738000 +6fcd0e27 cfb2690c 1 47525 2026-05-14 03:20:14.803000 +865c89ab 69de0d44 1 62453 2026-05-14 03:20:15.775000 +fa11e2dc c7769ac1 1 45822 2026-05-14 03:20:40.019000 +9f712bdd d59ff94a 1 59388 2026-05-14 03:20:41.012000 +f43b35d1 d7faa3db 1 46072 2026-05-14 03:21:07.793000 +073f6646 22a99558 1 55626 2026-05-14 03:21:08.878000 +a4a9dfa0 598e8e08 1 45352 2026-05-14 03:21:38.953000 +50f742fe 75734391 1 94265 2026-05-14 03:21:39.790000 +9431f455 58dee8ec 1 47366 2026-05-14 03:22:17.373000 +b5a95db6 28a75c2b 1 84555 2026-05-14 03:22:18.382000 +9eeb4ef8 6fe49ad5 1 4886 2026-05-14 03:23:35.069000 +9e5df77a f7b2d2bc 1 43913 2026-05-14 03:23:46.823000 +121aa320 d73eef9b 1 83835 2026-05-14 03:23:47.827000 +879c62b7 6ddfff72 1 59210 2026-05-14 03:24:24.182000 +f7f833ba 0b12c7bc 1 47954 2026-05-14 03:24:44.937000 +a1c37e43 b2caf455 1 45179 2026-05-14 03:24:45.899000 +41160bcd 4bb18e76 1 45011 2026-05-14 03:25:00.546000 +c921c161 f0bbf6ae 1 85345 2026-05-14 03:25:01.465000 +524765d8 2decb66a 1 47059 2026-05-14 03:25:34.135000 +cc926d1c 4a49bb9c 1 85315 2026-05-14 03:25:35.051000 +159a6f8e 7948a366 1 371015 2026-05-14 03:27:28.861000 +45f41435 17fbdcda 1 43913 2026-05-14 03:27:32.593000 +d3dc6c62 9055e214 1 265668 2026-05-14 03:27:33.643000 diff --git a/test-functional/results/01-findings.md b/test-functional/results/01-findings.md new file mode 100644 index 0000000..e9cc7dc --- /dev/null +++ b/test-functional/results/01-findings.md @@ -0,0 +1,111 @@ +# Test 1 findings — small task via opencode + +## What we asked +> Write a Python one-liner that reverses a string. Just the code, no +> explanation, no markdown fences. + +Trivial task. Expected: 1 user turn → 1 jbox file → `verified: true`. + +## What actually happened + +**14 jbox files** for one `opencode run`. Final outcome: opencode hit +the model's 65k-token context window and crashed: + +``` +litellm.ContextWindowExceededError: This model's maximum context +length is 65536 tokens. However, you requested 32000 output tokens +and your prompt contains at least 33537 input tokens. +``` + +Categorized by system prompt: + +| count | system-prompt prefix | what it is | +|-------|------------------------------------------------------|---------------------------| +| 7 | ` You are "Sisyphus"` | task-running agent (plugin) | +| 6 | `You are a helpful AI assistant tasked with summarizing conversations` | parallel summarizer | +| 1 | `You are a title generator` | thread-title generator | + +Plus my own `curl` sanity probe (1 file) ⇒ 15 total. The router +captured all of them correctly. + +## Why all 7 Sisyphus turns are SEPARATE sessions + +Our `SessionTracker.classify` requires the next request's `messages` +array to be a **strict prefix-extension** of the prior tip. Looking at +the Sisyphus blob message counts across time: + +``` +t1: 2 msgs (system + user) +t2: 7 msgs +t3: 4 msgs ← shrank! +t4: 7 msgs +t5: 4 msgs +t6: 4 msgs +t7: 4 msgs +``` + +The messages array **shrinks** between turns. That's not extension — +it's rewriting/compression done by the oh-my-opencode "Sisyphus" +agent runtime. Each non-extending turn → new `session_id` → new jbox +file. + +The 6 summarizer calls are independent for the same reason: each +summarizes a DIFFERENT slice of the agent's history, so they don't +share prefixes either. + +## What this says about the design + +**SessionTracker's strict-prefix assumption is too tight for real agent +platforms that do internal compression.** When the agent runtime +silently shortens history between turns, every turn looks like a new +conversation. + +Two options: + +**A) Accept it.** Document that with opencode-style agents, "one user +ask" produces N jbox files. TT correlates by `course_id` + `user_id` + +`ts` window, not by `session_id` alone. Storage stays O(per-turn) +which is what we tried to avoid with Design A. + +**B) Loosen SessionTracker.** Identify sessions by something more +stable than strict-prefix: + - hash of first user message + first system message → same session + - Or accept a `session_id` HTTP header from the client, fall back to + prefix detection if not present + - Or treat compression-induced shrinkage as continuation if the + first-user-message stable hash matches + +Option B is the right long-term answer if we expect agents to be the +primary use case. Option A is fine for human chat. + +## Other observations + +- **Plugin amplification**: my user's opencode has `oh-my-opencode` + plugins installed (`opencode-gemini-auth`, `oh-my-opencode`, + `@tarquinen/opencode-dcp`, `opencode-md-table-formatter`, + `opencode-pty`). Sisyphus comes from oh-my-opencode. A bare + `opencode run` might be substantially cleaner. **TODO: re-run + without plugins for a baseline.** +- **Rate-limit pressure**: 15 calls for one trivial ask × 10 req/min + cap = ~90 sec of agent loop already at the bound. Multi-turn agent + conversations against SJTU will get throttled. +- **Failure mode is upstream-side**: when opencode's context grows past + the model limit, the failure surfaces as an upstream 400, which the + router faithfully records as a `latest_response: {error: {...}}` + blob. `verified: true` against that error blob — the router itself + is fine. + +## Recommendation before continuing + +Before running tests 2–5 we should: + +1. **Decide on the SessionTracker question** (A vs B). If A, document + and move on. If B, design + implement before tests 3/4/5 (which all + involve multi-turn agent loops and would multiply the problem). +2. **Try a bare opencode** to confirm the plugin is the cause, not + opencode itself. Run with `--config /dev/null` or similar to + bypass `~/.config/opencode/opencode.json`'s plugin list. +3. **Add a stop-loss in the router**: when a session's blob exceeds N + MB, log a warning. The 95KB blob we saw is well under our config + pressure, but a real agent run with 1M-token context would blow up + jbox uploads if unchecked. diff --git a/test-functional/results/02-findings.md b/test-functional/results/02-findings.md new file mode 100644 index 0000000..7d6f767 --- /dev/null +++ b/test-functional/results/02-findings.md @@ -0,0 +1,87 @@ +# Test 2 findings — multimodal input/output + +## Setup +- model: `aimdware/qwen` (only SJTU model with working vision support; + glm 500'd, minimax rejected as non-multimodal, deepseek-* obviously text-only) +- attachment: 1×1 transparent PNG (68 bytes), `-f 02-image.png` to opencode +- prompt: "I'm attaching a 1x1 transparent PNG. Describe what you see…" + +## Router capture: WORKS + +My pre-test direct curl probes (no opencode, just `image_url` parts in +JSON) produced blobs with the OpenAI multimodal structure preserved: + +```json +"content": [ + { "type": "text", "text": "What's in the image?" }, + { "type": "image_url", "image_url": { "url": "data:image/png;base64,iVB..." } } +] +``` + +`first_user_content_type: "array"`, parts `["text", "image_url"]`, +base64 data round-trips intact. The router's `tryParseJSON` + +`canonicalize` path handles array `content` correctly, as already +covered by `session-blob.test.ts`. + +## opencode capture: image stripped by opencode's own Read tool + +When opencode received `-f 02-image.png`, Sisyphus called its **internal +Read tool** rather than emitting `image_url` parts. The Read tool's +response surfaced as a user message: + +``` +[0] system: You are "Sisyphus"... +[1] user (4 text parts): + [a] "Called the Read tool with the following input: {filePath:...02-image.png}" + [b] "Image read successfully" + [c] "ERROR: Cannot read 02-image.png (this model does not support + image input). Inform the user." + [d] "I'm attaching a 1x1 transparent PNG. Describe what you see..." +``` + +i.e. **Sisyphus base64-encoded zero bytes of the image**. The model +got 4 plain-text parts and no image data, then "described" the image +based on the filename + prompt alone. + +**Where the gate is**: opencode's per-provider capability registry. Our +test-functional/opencode.json declares a custom `aimdware` provider +with no model-capability metadata. opencode's default assumption for +unknown providers is "text-only", so the Read tool blocks image bytes +from reaching the model. + +## What gets captured under opencode + image attachment + +Total: 2 new records (smaller than Test 1's 17 because Sisyphus didn't +loop on a multimodal failure — it just gave up at Read): + +| blob | size | role | +|------------|-------|---------------------------------------| +| e12a0fce… | 5KB | title-generator thread (text only) | +| 55e5a81a… | 46KB | Sisyphus main thread (4 text parts; no image) | + +Both `verified: true`. Router did its job. + +## TT-relevant implications + +1. **Students can't easily smuggle images past opencode-based UI** + into custom providers — opencode's Read tool refuses. +2. **Students CAN smuggle images** by using a direct OpenAI-SDK call or + raw curl (we proved this works end-to-end against qwen). +3. **The captured blob's `content` field is the source of truth** for + what the student showed the model: array-of-parts means vision was + actually invoked, string means it was text-only. + +For TT auditing tools: when investigating "did student X show the +model an image", look for blobs where any +`messages[*].content[*].type == "image_url"` (or in the data-URI +fallback, check for `"data:image"` substring in any text part). + +## To extend this test + +We could tell opencode about the provider's vision capability via the +provider config (`"options.modelCapabilities"` or similar). That would +let `-f` produce real `image_url` parts. Worth doing if we want to +exercise the full multimodal path through opencode. + +For now: **router multimodal capture is verified, opencode multimodal +relay is a known gap we now understand**. diff --git a/test-functional/results/03-findings.md b/test-functional/results/03-findings.md new file mode 100644 index 0000000..df72fd4 --- /dev/null +++ b/test-functional/results/03-findings.md @@ -0,0 +1,99 @@ +# Test 3 findings — agent invokes a skill + +## Setup +- skill: `test-functional/skills/string-tools/SKILL.md` with frontmatter + `name: string-tools`, `description: ... Load when ... reverse a string OR count consonants ...` +- opencode config: `"skills": { "paths": ["./skills"] }` +- prompt: "Use the string-tools skill to reverse 'elephant', then count + unique consonants in the reversed result." +- model: `aimdware/deepseek-chat` + +## How opencode's skill mechanism actually works + +We discovered (by reading captured blobs) that opencode injects skill +**metadata** into the system prompt as XML: + +```xml + + string-tools + Reverse strings and count unique consonants. Load when the + user explicitly asks to reverse a string OR count consonants... + file:///.../skills/string-tools/SKILL.md + +``` + +Multiple `` blocks per skill discovered (also saw `taskbook-fill` +from oh-my-opencode's bundled skills). The system prompt instructs the +agent: + +> "For EVERY skill listed above, ask yourself: +> 'Does this skill's expertise domain overlap with my task?'" + +When the agent decides yes, it calls a `skill` tool to load the +SKILL.md body into the conversation. + +## What happened in our test + +13 router captures over ~2 minutes. The agent: + +- ✓ saw the skill listing in its system prompt (every Sisyphus blob + contains the `string-tools` block) +- ✗ **never invoked the `skill` tool** — zero blobs with a tool_call + for `skill` +- ✗ never loaded `count_unique_consonants` content — zero blobs + contain the SKILL.md body + +The agent inferred the procedures from the **description alone** and +computed the answer directly. That's reasonable for a trivial task. + +## Captured-blob inventory + +| count | thread | role | +|------:|---------------------------------------|------| +| 1 | title generator (5KB) | side | +| 1 | Sisyphus main (43KB, sees user task) | main | +| 1 | Sisyphus answering with computed reply| main | +| 10 | summarizer / introspection threads | side | + +Same per-task amplification as Test 1: 13 blobs for one user request. +**Crucial**: the user task itself is reconstructible from the Sisyphus +main blob; the 10 summarizers are agent-runtime overhead. + +## Router behavior: correct + +Every blob: +- captured the full system prompt including `` listings +- preserved any tool_calls / tool messages (none in this run, but + the data path is there — Test 4 will exercise this) +- `verified: true` on every record + +## TT-relevant signal + +**To detect whether a student's agent loaded a specific skill**: +look at any captured blob and find: + +```jq +.messages[] + | select(.role == "assistant") + | (.tool_calls // [])[] + | select(.function.name == "skill") + | .function.arguments +``` + +If present → the skill was loaded into the conversation; the +NEXT message (role=tool) contains the SKILL.md content the model saw. + +**To detect what skills were *advertised*** (whether loaded or not): +look at `.messages[0].content` (system prompt) for ``. + +These are two distinct audit questions. Both answerable from the +captured blob. + +## To extend + +For a task where the agent DOES invoke `skill`: pick a domain where +the SKILL.md content is non-trivial and the description alone isn't +sufficient. Maybe a SKILL with cryptographic procedures, or one with +a precise multi-step algorithm. The description is the gate; make it +intentionally vague ("Load when needed for cryptography") to force +the agent to load. diff --git a/test-functional/results/04-findings.md b/test-functional/results/04-findings.md new file mode 100644 index 0000000..6bea01d --- /dev/null +++ b/test-functional/results/04-findings.md @@ -0,0 +1,116 @@ +# Test 4 findings — agent calls MCP + +## Setup +- MCP server: `@modelcontextprotocol/server-filesystem` via bunx, sandbox at + `test-functional/mcp-sandbox/` containing exactly one file: + `HELLO.txt` with body `the magic phrase is: PURPLE-HORSE-42`. +- opencode config (project-local): + ```json + "mcp": { + "fs": { + "type": "local", + "command": ["bunx", "-y", "@modelcontextprotocol/server-filesystem", + "/Users/.../test-functional/mcp-sandbox"] + } + } + ``` +- Model: `aimdware/deepseek-chat` +- Prompt: ask the agent to read the only file in the MCP sandbox and + return the magic phrase verbatim. + +## What happened + +**The agent never actually invoked an MCP tool.** 9 captures, zero +tool_calls, the magic phrase `PURPLE-HORSE-42` never appears in any +blob. + +Why: opencode + Sisyphus + the registered MCP server collectively +inflated the system prompt past the DeepSeek 65k context window. The +captured blob's summarizer thread shows it explicitly: + +``` +[3] user: "The previous request exceeded the provider's size limit + due to large media attachments. The conversation was + compacted and media files were removed from context. ..." +``` + +(opencode mis-labels the cause as "media attachments" — there were +none. The real culprit is the system-prompt explosion from registering +all opencode plugins + MCP tool schemas.) + +The agent then looped on summarize → "what did we do so far?" → +context-overflow → compact → repeat, until we killed it at 9 +captures. + +## What this exposes in our router + +A real router-side concern came up that's worth fixing: + +**Router does NOT capture the request's `tools` array** — only +`messages`. The OpenAI chat-completion request shape is: + +```json +{ + "model": "...", + "messages": [ ... ], + "tools": [ ← we throw this away + { "type": "function", + "function": { "name": "fs_read_file", "parameters": {...} } }, + ... + ], + "tool_choice": "..." ← also thrown away +} +``` + +So a TT can see WHEN an agent used a tool (the assistant message has +`tool_calls`) but cannot see WHAT tools were available to it. That's +fixable: extend `buildSessionBlob` to also persist `tools` / +`tool_choice` / any other top-level chat-completion fields. + +## What this exposes in opencode + +- opencode's "skill" plugin and "MCP server" plugin both contribute to + the system prompt. With oh-my-opencode + several plugins + 1 MCP + server, just the boilerplate easily fills 30-40k tokens before any + user content. +- The 65k DeepSeek-chat ceiling is therefore very easy to bust for + any non-trivial task. +- opencode's response is to "compact" — but the compact also re-runs + the model on the SAME bloated system prompt, so the next call also + overflows. We saw this loop in real time. + +## TT-relevant implications + +1. **MCP-based attacks/leaks would be detectable IF the agent succeeded** + — `tool_calls` and tool results land in `messages` and get captured. + But context-window failures suppress execution entirely; we'd + see the intent (in `messages[1].content` user prompt) but never the + execution. + +2. **A determined student CAN use MCP** via a direct curl or via a + leaner client (raw OpenAI SDK + their own MCP loop). The blob would + then carry the tool_calls and tool_responses cleanly. + +3. **What the router captures is correct** — the failure is purely + on opencode's side. Same as Test 2: capture works, the client + doesn't reach the multimodal/MCP path because its own + orchestration mishandles it. + +## Suggested follow-up + +- **Patch `buildSessionBlob` to also persist `tools` and `tool_choice`** + — small, useful for audit completeness. +- **Document opencode's context-overflow loop** as a known agent + pathology for students who pick DeepSeek-chat. Recommend + `deepseek-reasoner` (128k) or `qwen` (likely larger) for any + agent / MCP workload. + +## Total captures so far across all tests + +- Test 1: 17 blobs (Sisyphus + summarizers, context overflow) +- Test 2: 2 blobs (image stripped by opencode Read tool) +- Test 3: 13 blobs (skill description in system prompt; never loaded) +- Test 4: 9 blobs (MCP registered; never invoked due to context overflow) + +≈ 41 captures total. Router validated end-to-end on every one +(`verified=true`). diff --git a/test-functional/results/05-findings.md b/test-functional/results/05-findings.md new file mode 100644 index 0000000..723a079 --- /dev/null +++ b/test-functional/results/05-findings.md @@ -0,0 +1,112 @@ +# Test 5 findings — context compression effect on history + +## Setup +- model: `aimdware/deepseek-reasoner` (128k context — gives headroom + before forced compaction) +- prompt: "Enumerate the first 10 perfect squares, for each say if its + digit sum is itself a perfect square." +- Intent: deepseek-reasoner is a thinking-trace model. Its responses + are verbose. Combined with Sisyphus's orchestration, we expected + to see compression behavior in action. + +## What we observed + +3 captures over ~3 minutes (killed at the cap): + +| ts | msgs | size | thread | +|-----------|------|-----------|--------------------| +| 11:27:32 | 2 | 43 KB | Sisyphus main (sees user task) | +| 11:29:13 | 4 | 265 KB | summarizer sub-thread | +| 11:29:42 | 3 | **371 KB**| title-generator sub-thread | + +The msg-count went **2 → 4 → 3** — NOT extending. Each blob is +classified by SessionTracker as a separate session, correctly: + +- blob 1: `system: ` + user task +- blob 2: `system: summarizer role` + user task + `What did we do so far?` + `Continue...` +- blob 3: `system: title-generator role` + `Generate a title:` + user task + +Three different system prompts → three different `messages[0]` → three +separate sessions per our prefix-extension rule. + +## The 371KB title-generator is the eye-opener + +The biggest blob (371KB) is the title-generator thread. The +title-generator's job is trivial — emit a ≤50-char string naming the +conversation. But it received the **entire deepseek-reasoner assistant +output verbatim** as its user message [2]: + +``` +[0] system: "You are a title generator. Output ONLY a thread title..." +[1] user: "Generate a title for this conversation:" +[2] user: "" +``` + +So opencode pastes the WHOLE assistant response into a title-generator +sub-call. The title-generator runs the LLM again on 350KB of context +just to produce a 50-char title. **This is per-task overhead that +scales with the assistant response size**, not the user prompt size. + +Implication for the router: a single user "small task" can produce +multiple multi-hundred-KB jbox uploads, each of which is the same +assistant content seen by a different sub-agent. Cost on jbox storage +grows fast. + +## How compression affects what gets captured + +We **did NOT** see opencode visibly compact the user's task itself +(unlike Test 4 where MCP context blew the window). What we DID see +matches an earlier pattern from Test 1's blob t2: + +``` +[3] user: "The previous request exceeded the provider's size limit + due to large media attachments. The conversation was + compacted and media files were removed from context. ..." +``` + +When opencode does compact, **it rewrites earlier messages in the +array** and re-sends. From our SessionTracker's strict-prefix +perspective, the post-compaction request is NOT a prefix-extension of +the pre-compaction request, so a new session_id is minted. The +compaction creates a session boundary. + +## Conclusion across all 5 tests + +| # | What it tested | Router behavior | opencode behavior | +|---|----------------------------|----------------------|----------------------------------------------| +| 1 | Single task | ✓ captured 17 blobs | Sisyphus loops + summarizers + title gen | +| 2 | Multimodal | ✓ captures `image_url` parts | Read tool strips image for custom providers | +| 3 | Skill invocation | ✓ captures `` listings | Agent inferred from description, didn't `skill`-tool-load | +| 4 | MCP | ✓ would capture tool_calls | Context overflow loop; never invoked MCP | +| 5 | Compression | ✓ each rewrite = new session | Title-gen + summarizer add ≈700KB per real turn | + +**Router-side**: every single capture across 5 tests has +`verified=true`. The router is correct end-to-end against the real +SJTU upstream. + +**opencode + agent platform overhead**: a non-trivial issue we now +have data on. The 41+ blobs across 5 tests are mostly side-thread +overhead (titles, summaries, introspection), not direct task content. +TT tooling needs to filter this for usability. + +## Design recommendations from the test campaign + +1. **Capture `tools` / `tool_choice`** on the blob — addressed in + commit 509a670 (post-Test 4). A TT can now see what tools were + advertised, not only which were invoked. + +2. **Surface the "thread fingerprint"** in the TT view: group blobs + by `messages[0]` (system prompt) hash. Sisyphus / title-gen / + summarizer threads cluster naturally that way. + +3. **Document the per-task amplification** in admin docs: + "expect 10-30 blobs per opencode-driven user task". If TT pays + per-storage on jbox, this is the budgeting number. + +4. **Maybe (later, optional)**: relax SessionTracker. Today it's + strict-prefix — perfect for users hitting the API directly, + misses N opportunities to merge with agent platforms. Could add a + second-pass merger that links sessions sharing `messages[0]` + hash + user-message hash within an N-minute window. This is the + "Option B" from Test 1's findings, deferred until we have a + real consumer asking for it. diff --git a/test-functional/skills/string-tools/SKILL.md b/test-functional/skills/string-tools/SKILL.md new file mode 100644 index 0000000..e11073e --- /dev/null +++ b/test-functional/skills/string-tools/SKILL.md @@ -0,0 +1,21 @@ +--- +name: string-tools +description: Reverse strings and count unique consonants. Load when the user explicitly asks to reverse a string OR count consonants in a result. +--- + +# string-tools + +Two procedures. + +## reverse(s) +Return `s` reversed character by character. +- reverse("elephant") → "tnahpele" + +## count_unique_consonants(s) +Return the number of distinct consonants in `s` (case-insensitive). +Treat aeiou as vowels; everything else alphabetic is a consonant. +Digits and punctuation are ignored. +- count_unique_consonants("tnahpele") → distinct letters in s minus vowels: + {t,n,a,h,p,e,l,e} → consonants only: {t,n,h,p,l} → **5** + +Apply both procedures when invoked. Show your work briefly. diff --git a/test-functional/teardown.sh b/test-functional/teardown.sh new file mode 100755 index 0000000..bec483d --- /dev/null +++ b/test-functional/teardown.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Kill backend + router, optionally clean Tbox course subdir. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [ -z "${WORK:-}" ]; then + echo "WORK env not set — pick the latest one" + WORK="$(ls -1dt "$REPO_ROOT"/test-functional/runs/* 2>/dev/null | head -1)" + [ -n "$WORK" ] || { echo "no runs/ found"; exit 1; } + echo " using $WORK" +fi + +for pidf in "$WORK"/backend.pid "$WORK"/router.pid; do + [ -f "$pidf" ] || continue + PID="$(cat "$pidf")" + kill "$PID" 2>/dev/null || true +done + +# Best-effort Tbox cleanup for the course folder, unless KEEP=1. +if [ -z "${KEEP:-}" ] && [ -n "${COURSE:-}" ]; then + TBOX_URL="${AIMDWARE_TBOX_URL:-http://127.0.0.1:50471}" + TBOX_USER="${AIMDWARE_TBOX_USER:-admin}" + TBOX_PASS="${AIMDWARE_TBOX_PASS:-admin}" + curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE \ + "$TBOX_URL/aimdware/$COURSE" >/dev/null 2>&1 || true + echo "Tbox: deleted /aimdware/$COURSE" +fi + +echo "torn down (workdir kept at $WORK)" diff --git a/test-scripts/concurrency/child.ts b/test-scripts/concurrency/child.ts new file mode 100644 index 0000000..8d4b373 --- /dev/null +++ b/test-scripts/concurrency/child.ts @@ -0,0 +1,48 @@ +// One worker process of the cross-process codex-refresh concurrency test. +// Spawned by run.ts; talks to a fake OAuth server and shares one auth.json +// with its siblings. See run.ts for the harness and assertions. +import { createCodexProvider } from "../../llm-client/src/providers/codex.ts"; +import { + authFilePath, + createFileAuthStore, +} from "../../llm-client/src/providers/auth-store.ts"; + +const cacheDir = process.argv[2]!; +const oauthPort = Number(process.argv[3]); +const mode = process.argv[4] ?? "lock"; +const barrierPort = Number(process.argv[5]); + +// Block at the barrier so every sibling fires its refresh simultaneously +// instead of being staggered by process startup. +await fetch(`http://127.0.0.1:${barrierPort}/`).catch(() => {}); + +const base = createFileAuthStore(authFilePath(cacheDir)); +// "nolock" strips withLock so codex falls back to its in-process-only path, +// reproducing the behaviour from before the cross-process file lock existed. +const store: any = + mode === "nolock" + ? { get: base.get, set: base.set, del: base.del } + : base; + +const fetchImpl = async (input: any, init: any) => { + const u = new URL(String(input)); + // Talk to the local fake OAuth server, ignoring any proxy from the env. + const rest = { ...init }; + delete rest.proxy; + return fetch(`http://127.0.0.1:${oauthPort}${u.pathname}`, rest); +}; + +const provider = createCodexProvider({ authStore: store, fetchImpl }); +try { + const prepared = await provider.prepareResponses({ + inboundUrl: new URL("http://x/v1/responses"), + method: "POST", + headers: new Headers(), + body: undefined, + } as any); + console.log("OK " + prepared.headers.get("authorization")); + process.exit(0); +} catch (e: any) { + console.log("ERR " + (e?.message ?? e)); + process.exit(2); +} diff --git a/test-scripts/concurrency/run.ts b/test-scripts/concurrency/run.ts new file mode 100644 index 0000000..1f062a7 --- /dev/null +++ b/test-scripts/concurrency/run.ts @@ -0,0 +1,154 @@ +// Cross-process concurrency test for codex subscription token refresh. +// +// Spawns N real OS processes that all refresh the same expired credential at +// once (synchronized by a barrier) against a fake OAuth server with single-use +// rotating refresh tokens. The server holds the winner mid-refresh for 50ms so +// the losers are guaranteed to collide while the rotation is uncommitted. +// +// In "lock" mode (the shipping behaviour) the cross-process file lock must make +// exactly one network refresh happen and let every process succeed. The run is +// repeated and asserted; it exits non-zero on any violation. A single "nolock" +// run is printed afterwards purely as an informational contrast. +// +// bun run test-scripts/concurrency/run.ts +// CONC_PROCESSES=8 CONC_RUNS=3 bun run test-scripts/concurrency/run.ts +import { + authFilePath, + createFileAuthStore, +} from "../../llm-client/src/providers/auth-store.ts"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const N = Number(process.env.CONC_PROCESSES ?? 8); +const RUNS = Number(process.env.CONC_RUNS ?? 3); +const childPath = join(import.meta.dir, "child.ts"); + +type Outcome = { + rotations: number; + ok: number; + err: number; + final: unknown; + outs: string[]; +}; + +async function once(mode: "lock" | "nolock"): Promise { + const cacheDir = mkdtempSync(join(tmpdir(), "aimdware-conc-")); + + // Fake OAuth endpoint with single-use rotating refresh tokens. + let validRefresh = "R0"; + let rotations = 0; + const oauth = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const params = new URLSearchParams(await req.text()); + if (params.get("refresh_token") === validRefresh) { + rotations++; + validRefresh = "R" + rotations; + // Hold the winner so any loser is in flight (or blocked on the lock) + // while this rotation is still uncommitted. + await new Promise((r) => setTimeout(r, 50)); + return Response.json({ + access_token: "acc" + rotations, + refresh_token: validRefresh, + expires_in: 3600, + }); + } + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }, + }); + + // Barrier: release all children at once so the refreshes truly collide. + let waiters: Array<(r: Response) => void> = []; + const barrier = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Promise((resolve) => { + waiters.push(resolve); + if (waiters.length >= N) { + for (const w of waiters) w(new Response("go")); + waiters = []; + } + }); + }, + }); + + const store = createFileAuthStore(authFilePath(cacheDir)); + await store.set("codex", { + type: "oauth", + access: "old", + refresh: "R0", + expires: 1, + }); + + const procs = Array.from({ length: N }, () => + Bun.spawn( + [ + "bun", + "run", + childPath, + cacheDir, + String(oauth.port), + mode, + String(barrier.port), + ], + { stdout: "pipe", stderr: "pipe" }, + ), + ); + const outs = await Promise.all( + procs.map(async (p) => { + const out = await new Response(p.stdout).text(); + await p.exited; + return out.trim(); + }), + ); + + const final = await store.get("codex"); + oauth.stop(true); + barrier.stop(true); + rmSync(cacheDir, { recursive: true, force: true }); + + return { + rotations, + ok: outs.filter((o) => o.startsWith("OK")).length, + err: outs.filter((o) => o.startsWith("ERR")).length, + final, + outs, + }; +} + +const access = (final: unknown) => + final ? (final as { access?: string }).access : "DELETED"; + +let failed = false; +for (let i = 1; i <= RUNS; i++) { + const r = await once("lock"); + // With the lock: exactly one network refresh, every process succeeds, and + // the credential survives. + const pass = r.rotations === 1 && r.ok === N && r.err === 0 && Boolean(r.final); + console.log( + `[lock run ${i}] rotations=${r.rotations} OK=${r.ok} ERR=${r.err} ` + + `final=${access(r.final)} -> ${pass ? "PASS" : "FAIL"}`, + ); + if (!pass) { + failed = true; + r.outs.forEach((o, j) => console.log(` child${j}: ${o}`)); + } +} + +const c = await once("nolock"); +console.log( + `[nolock contrast] rotations=${c.rotations} OK=${c.ok} ERR=${c.err} ` + + `final=${access(c.final)} (informational: pre-lock behaviour)`, +); + +if (failed) { + console.error("CONCURRENCY TEST FAILED"); + process.exit(1); +} +console.log("CONCURRENCY TEST PASSED"); diff --git a/test-scripts/smoke_e2e.sh b/test-scripts/smoke_e2e.sh new file mode 100755 index 0000000..14dea54 --- /dev/null +++ b/test-scripts/smoke_e2e.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# E2E smoke: real backend + router + fake upstream + fake WebDAV. +# Verifies a chat request lands as a ContextRecord in the backend DB +# with blob_status=uploaded. +# +# Run from the repo root: ./test-scripts/smoke_e2e.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d -t aimdware-e2e-XXXX)" +trap 'echo "--- cleanup ---"; jobs -p | xargs -r kill 2>/dev/null; rm -rf "$WORK"' EXIT + +DB_FILE="$WORK/aimdware.db" +CACHE_DIR="$WORK/cache" +BACKEND_PORT="$((20000 + RANDOM % 30000))" +ROUTER_PORT="$((20000 + RANDOM % 30000))" +UPSTREAM_PORT="$((20000 + RANDOM % 30000))" +TBOX_PORT="$((20000 + RANDOM % 30000))" + +echo "--- workdir: $WORK ---" +echo " db: $DB_FILE" +echo " backend: :$BACKEND_PORT" +echo " router: :$ROUTER_PORT" +echo " upstream: :$UPSTREAM_PORT" +echo " tbox: :$TBOX_PORT" + +# 1. backend (real, sqlite-on-disk) +export AIMDWARE_DATABASE_URL="sqlite:///$DB_FILE" +( + cd "$REPO_ROOT/backend" + uv run uvicorn aimdware_backend.main:app --port "$BACKEND_PORT" --log-level warning +) >"$WORK/backend.log" 2>&1 & + +# 2. fake upstream LLM +bun -e " +Bun.serve({ + port: $UPSTREAM_PORT, hostname: '127.0.0.1', + fetch: () => new Response('{\"id\":\"smoke-upstream\",\"choices\":[{\"message\":{\"content\":\"OK\"}}]}', { + status: 200, headers: { 'content-type': 'application/json' } + }) +}); +" >"$WORK/upstream.log" 2>&1 & + +# 3. fake Tbox (WebDAV PUT acceptor — handles MKCOL too since the router +# now calls createDirectory(parent, {recursive: true}) before each PUT). +bun -e " +Bun.serve({ + port: $TBOX_PORT, hostname: '127.0.0.1', + async fetch(req) { + const u = new URL(req.url); + if (req.method === 'PUT') { + const body = await req.arrayBuffer(); + console.log('PUT', u.pathname, body.byteLength); + return new Response('', { status: 201 }); + } + if (req.method === 'MKCOL') { + console.log('MKCOL', u.pathname); + return new Response('', { status: 201 }); + } + if (req.method === 'PROPFIND') { + // recursive MKCOL probes existence first; pretend nothing exists + // so it always proceeds to create. + return new Response('', { status: 404 }); + } + return new Response('', { status: 200, headers: { DAV: '1,2' } }); + } +}); +" >"$WORK/tbox.log" 2>&1 & + +# wait for backend +for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$BACKEND_PORT/ingest/health" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# 4. seed user / course / enrollment / token +export E2E_PLAINTEXT="st_E2E_SMOKE_TEST_TOKEN_xxxxxxxxxxxx" +TOKEN="$( + cd "$REPO_ROOT/backend" + AIMDWARE_DATABASE_URL="$AIMDWARE_DATABASE_URL" \ + uv run python scripts/seed_for_e2e.py +)" +echo "--- seeded token: ${TOKEN:0:8}… ---" + +# 5. router config +cat >"$WORK/aimdware.yaml" <"$WORK/router.log" 2>&1 & + +# wait for router +for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$ROUTER_PORT/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# 7. fire a chat completion +echo "--- chat ---" +curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ + -H "content-type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"smoke"}]}' +echo + +# 8. wait for ingest -> sync -> confirm (3 stages × ~1s worker poll) +sleep 8 + +# 9. inspect db +echo "--- ContextRecord rows ---" +sqlite3 "$DB_FILE" "SELECT id, model, blob_size, blob_status FROM context_records;" + +# 10. confirm at least one record with status=uploaded +COUNT="$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM context_records WHERE blob_status = 'uploaded';")" +if [ "$COUNT" -ge 1 ]; then + echo "--- PASS: $COUNT record(s) with blob_status=uploaded ---" +else + echo "--- FAIL: 0 records with blob_status=uploaded ---" + echo "--- router log tail ---" + tail -20 "$WORK/router.log" + echo "--- backend log tail ---" + tail -20 "$WORK/backend.log" + exit 1 +fi diff --git a/test-scripts/smoke_e2e_agent.sh b/test-scripts/smoke_e2e_agent.sh new file mode 100755 index 0000000..08c1188 --- /dev/null +++ b/test-scripts/smoke_e2e_agent.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Multi-turn agent smoke against the REAL Tbox. Proves Design A: +# - 3 turns of the same growing conversation produce 3 records +# but ONE blob file on jbox (session-keyed, overwritten each turn). +# - /admin/session//payload returns verified=true against the +# latest turn's hash. +# +# Env (optional): +# AIMDWARE_TBOX_URL (default http://127.0.0.1:50471) +# AIMDWARE_TBOX_USER (default admin) +# AIMDWARE_TBOX_PASS (default admin) +# KEEP_TBOX_DATA=1 keep the per-run Tbox subdir + local cache +# +# Run from repo root. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d -t aimdware-agent-smoke-XXXX)" + +TBOX_URL="${AIMDWARE_TBOX_URL:-http://127.0.0.1:50471}" +TBOX_USER="${AIMDWARE_TBOX_USER:-admin}" +TBOX_PASS="${AIMDWARE_TBOX_PASS:-admin}" + +STAMP="$(date +%s)-$$" +COURSE="AGENT${STAMP}" +ASSIGNMENT="smoke" +# Blobs land under //; we PROPFIND/DELETE the +# course parent to cover both the assignment subdir and the dir itself. +TBOX_COURSE_DIR="aimdware/$COURSE" +TBOX_ASSIGNMENT_DIR="aimdware/$COURSE/$ASSIGNMENT" +ADMIN_SECRET="agent-admin-secret-$STAMP" + +DB_FILE="$WORK/aimdware.db" +CACHE_DIR="$WORK/cache" +BACKEND_PORT="$((20000 + RANDOM % 30000))" +ROUTER_PORT="$((20000 + RANDOM % 30000))" +UPSTREAM_PORT="$((20000 + RANDOM % 30000))" + +cleanup() { + echo "--- cleanup ---" + jobs -p | xargs -r kill 2>/dev/null || true + if [ -n "${KEEP_TBOX_DATA:-}" ]; then + echo " KEEP_TBOX_DATA=1 — leaving $TBOX_COURSE_DIR on Tbox" + echo " inspect: curl -u $TBOX_USER:$TBOX_PASS $TBOX_URL/$TBOX_ASSIGNMENT_DIR/" + else + curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE "$TBOX_URL/$TBOX_COURSE_DIR" >/dev/null 2>&1 || true + rm -rf "$WORK" + fi +} +trap cleanup EXIT + +echo "--- workdir: $WORK ---" +echo " course: $COURSE" + +# Probe Tbox. +if ! curl -sS -u "$TBOX_USER:$TBOX_PASS" -o /dev/null -w "%{http_code}\n" \ + "$TBOX_URL/" | grep -qE '^(200|207|401)$'; then + echo "FAIL: Tbox at $TBOX_URL is not responding"; exit 1 +fi + +# Backend. +export AIMDWARE_DATABASE_URL="sqlite:///$DB_FILE" +export AIMDWARE_TBOX_URL="$TBOX_URL" +export AIMDWARE_TBOX_USER="$TBOX_USER" +export AIMDWARE_TBOX_PASS="$TBOX_PASS" +export AIMDWARE_ADMIN_SECRET="$ADMIN_SECRET" +(cd "$REPO_ROOT/backend" && uv run uvicorn aimdware_backend.main:app --port "$BACKEND_PORT" --log-level warning) >"$WORK/backend.log" 2>&1 & + +# Fake upstream — echoes the turn number. +bun -e " +let turn = 0; +Bun.serve({ port: $UPSTREAM_PORT, hostname: '127.0.0.1', async fetch() { + turn++; + return new Response(JSON.stringify({ + id: 'turn-' + turn, + choices: [{ message: { role: 'assistant', content: 'reply #' + turn } }] + }), { headers: { 'content-type': 'application/json' } }); +}});" >"$WORK/upstream.log" 2>&1 & + +for i in $(seq 1 50); do + curl -sf "http://127.0.0.1:$BACKEND_PORT/ingest/health" >/dev/null 2>&1 && break + sleep 0.2 +done + +# Seed user + course + token. +export E2E_PLAINTEXT="st_AGENT_SMOKE_TOKEN_xxxxxxxxxxxx" +TOKEN="$(cd "$REPO_ROOT/backend" && E2E_COURSE="$COURSE" \ + AIMDWARE_DATABASE_URL="$AIMDWARE_DATABASE_URL" uv run python scripts/seed_for_e2e.py)" + +# Router config. +cat >"$WORK/aimdware.yaml" <"$WORK/router.log" 2>&1 & + +for i in $(seq 1 50); do + curl -sf "http://127.0.0.1:$ROUTER_PORT/healthz" >/dev/null 2>&1 && break + sleep 0.2 +done + +# Three "agent" turns — each turn re-sends the full history. +MSGS='[{"role":"user","content":"start a todo app"}]' +for turn in 1 2 3; do + REQ=$(jq -nc --argjson m "$MSGS" '{model:"gpt-4o",messages:$m}') + RESP=$(curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ + -H 'content-type: application/json' -d "$REQ") + ASSISTANT=$(echo "$RESP" | jq -r '.choices[0].message.content') + echo "turn $turn sent=$(echo "$MSGS" | jq 'length') msgs got: $ASSISTANT" + MSGS=$(echo "$MSGS" | jq -c --arg a "$ASSISTANT" --arg next "next step for turn $((turn+1))?" \ + '. + [{role:"assistant",content:$a},{role:"user",content:$next}]') +done + +sleep 8 + +# Backend DB: 3 records, all sharing one session_id, turn_count 1/2/3 +echo +echo "--- backend records (all should share session_id) ---" +sqlite3 "$DB_FILE" -header -column \ + "SELECT substr(id,1,8) AS record, session_id, turn_count, blob_status, blob_size FROM context_records ORDER BY turn_count;" + +DISTINCT=$(sqlite3 "$DB_FILE" "SELECT COUNT(DISTINCT session_id) FROM context_records;") +TOTAL=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM context_records;") +[ "$DISTINCT" = "1" ] || { echo "FAIL: expected 1 distinct session, got $DISTINCT"; exit 1; } +[ "$TOTAL" = "3" ] || { echo "FAIL: expected 3 records, got $TOTAL"; exit 1; } +echo "OK: $TOTAL records / $DISTINCT session" + +# jbox: exactly ONE blob file under /aimdware/$COURSE/ +echo +echo "--- jbox listing (should be ONE file, not three) ---" +COUNT=$(curl -s -u "$TBOX_USER:$TBOX_PASS" -X PROPFIND -H "Depth: 1" "$TBOX_URL/$TBOX_ASSIGNMENT_DIR/" \ + | grep -oE '[^<]+\.json' | wc -l | tr -d ' ') +echo " json files on jbox under /$TBOX_ASSIGNMENT_DIR/: $COUNT" +[ "$COUNT" = "1" ] || { echo "FAIL: expected exactly 1 blob, got $COUNT"; exit 1; } + +# Session-level verify endpoint +SESSION_ID=$(sqlite3 "$DB_FILE" "SELECT session_id FROM context_records LIMIT 1;") +SESSION_ID_FMT="${SESSION_ID:0:8}-${SESSION_ID:8:4}-${SESSION_ID:12:4}-${SESSION_ID:16:4}-${SESSION_ID:20:12}" + +echo +echo "--- /admin/session/$SESSION_ID_FMT/payload ---" +RESP=$(curl -sS -H "Authorization: Bearer $ADMIN_SECRET" \ + "http://127.0.0.1:$BACKEND_PORT/admin/session/$SESSION_ID_FMT/payload") +echo "$RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(f\" session_id {d['session_id']}\") +print(f\" turn_count {d['turn_count']}\") +print(f\" blob_size {d['blob_size_actual']}\") +print(f\" verified {d['verified']}\") +payload = json.loads(d['payload_utf8']) +print(f\" blob has {len(payload['request']['messages'])} messages in final state\") +print(f\" blob's turn_count field: {payload['turn_count']}\") +" +VERIFIED=$(echo "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["verified"])') +[ "$VERIFIED" = "True" ] || { echo "FAIL: session payload not verified"; exit 1; } + +echo +echo "--- PASS: 3 turns -> 1 jbox file -> verified ---" diff --git a/test-scripts/smoke_e2e_real_tbox.sh b/test-scripts/smoke_e2e_real_tbox.sh new file mode 100755 index 0000000..8ff9c97 --- /dev/null +++ b/test-scripts/smoke_e2e_real_tbox.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# E2E smoke with the REAL Tbox WebDAV server. +# +# Flow: +# 1. start backend (real, sqlite on disk) wired to real Tbox creds +# 2. start a fake upstream LLM (we don't want to depend on a real provider) +# 3. seed user + UNIQUE course + token in backend +# 4. start router with config pointing at real Tbox + admin auth +# 5. fire a chat completion through the router +# 6. assert: a ContextRecord exists with blob_status=uploaded +# 7. assert: /admin/context/{id}/payload reads back from Tbox AND hash verifies +# 8. cleanup: DELETE the course-scoped subdir from Tbox +# +# Configurable via env: +# AIMDWARE_TBOX_URL (default http://127.0.0.1:50471) +# AIMDWARE_TBOX_USER (default admin) +# AIMDWARE_TBOX_PASS (default admin) +# +# Run from the repo root: ./test-scripts/smoke_e2e_real_tbox.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d -t aimdware-e2e-real-XXXX)" + +TBOX_URL="${AIMDWARE_TBOX_URL:-http://127.0.0.1:50471}" +TBOX_USER="${AIMDWARE_TBOX_USER:-admin}" +TBOX_PASS="${AIMDWARE_TBOX_PASS:-admin}" + +# Unique per-run course so each smoke run gets its own Tbox subdir. +STAMP="$(date +%s)-$$" +COURSE="SMOKE${STAMP}" +TBOX_SUBDIR="aimdware/$COURSE" +ADMIN_SECRET="smoke-admin-secret-$STAMP" + +DB_FILE="$WORK/aimdware.db" +CACHE_DIR="$WORK/cache" +BACKEND_PORT="$((20000 + RANDOM % 30000))" +ROUTER_PORT="$((20000 + RANDOM % 30000))" +UPSTREAM_PORT="$((20000 + RANDOM % 30000))" + +cleanup() { + echo "--- cleanup ---" + jobs -p | xargs -r kill 2>/dev/null || true + if [ -n "${KEEP_TBOX_DATA:-}" ]; then + echo " KEEP_TBOX_DATA=1 — leaving $TBOX_SUBDIR on Tbox and $WORK on disk" + echo " inspect: curl -u $TBOX_USER:$TBOX_PASS $TBOX_URL/$TBOX_SUBDIR/" + else + curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE "$TBOX_URL/$TBOX_SUBDIR" >/dev/null 2>&1 || true + rm -rf "$WORK" + fi +} +trap cleanup EXIT + +echo "--- workdir: $WORK ---" +echo " db: $DB_FILE" +echo " backend: :$BACKEND_PORT" +echo " router: :$ROUTER_PORT" +echo " upstream: :$UPSTREAM_PORT" +echo " tbox: $TBOX_URL (real, user=$TBOX_USER)" +echo " course: $COURSE" + +# 0. Probe Tbox before anything else. +if ! curl -sS -u "$TBOX_USER:$TBOX_PASS" -o /dev/null -w "%{http_code}\n" \ + "$TBOX_URL/" | grep -qE '^(200|207|401)$'; then + echo "FAIL: Tbox at $TBOX_URL is not responding" + exit 1 +fi + +# 1. backend wired to real Tbox creds + admin secret +export AIMDWARE_DATABASE_URL="sqlite:///$DB_FILE" +export AIMDWARE_TBOX_URL="$TBOX_URL" +export AIMDWARE_TBOX_USER="$TBOX_USER" +export AIMDWARE_TBOX_PASS="$TBOX_PASS" +export AIMDWARE_ADMIN_SECRET="$ADMIN_SECRET" +( + cd "$REPO_ROOT/backend" + uv run uvicorn aimdware_backend.main:app --port "$BACKEND_PORT" --log-level warning +) >"$WORK/backend.log" 2>&1 & + +# 2. fake upstream +bun -e " +Bun.serve({ + port: $UPSTREAM_PORT, hostname: '127.0.0.1', + fetch: () => new Response(JSON.stringify({ + id: 'smoke-real-tbox', + choices: [{ message: { content: 'OK from real-tbox smoke' } }] + }), { status: 200, headers: { 'content-type': 'application/json' } }) +}); +" >"$WORK/upstream.log" 2>&1 & + +# wait for backend +for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$BACKEND_PORT/ingest/health" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# 3. seed user/course/enrollment/token (custom course!) +export E2E_PLAINTEXT="st_E2E_REAL_TBOX_TOKEN_xxxxxxxxxxxx" +TOKEN="$( + cd "$REPO_ROOT/backend" + E2E_COURSE="$COURSE" \ + AIMDWARE_DATABASE_URL="$AIMDWARE_DATABASE_URL" \ + uv run python scripts/seed_for_e2e.py +)" +echo "--- seeded token: ${TOKEN:0:8}… course: $COURSE ---" + +# 4. router config -> real Tbox with creds +cat >"$WORK/aimdware.yaml" <"$WORK/router.log" 2>&1 & + +for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:$ROUTER_PORT/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +# 6. fire a chat +echo "--- chat ---" +curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ + -H "content-type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello real tbox"}]}' +echo + +# 7. wait for ingest -> sync -> confirm +sleep 8 + +# 8. inspect db +echo "--- ContextRecord rows (backend) ---" +sqlite3 "$DB_FILE" "SELECT id, model, blob_size, blob_status, hex(blob_hash) FROM context_records;" + +echo "--- outbox rows (router) ---" +sqlite3 "$CACHE_DIR/queue.db" "SELECT record_id, state, attempts, last_error FROM outbox;" || true + +COUNT="$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM context_records WHERE blob_status = 'uploaded';")" +if [ "$COUNT" -lt 1 ]; then + echo "FAIL: 0 records reached blob_status=uploaded" + echo "--- router log tail ---"; tail -25 "$WORK/router.log" + echo "--- backend log tail ---"; tail -25 "$WORK/backend.log" + exit 1 +fi +echo "--- $COUNT record(s) marked uploaded ---" + +# 9. round-trip verify via /admin/context/{id}/payload — proves the blob +# actually landed in Tbox and the stored hash matches what's there. +# SQLAlchemy stores UUID as a 32-char string in sqlite — read directly, +# then format with hyphens for the URL. +RAW_ID="$(sqlite3 "$DB_FILE" "SELECT id FROM context_records LIMIT 1;")" +ID="${RAW_ID:0:8}-${RAW_ID:8:4}-${RAW_ID:12:4}-${RAW_ID:16:4}-${RAW_ID:20:12}" +echo "--- verify payload via /admin/context/$ID/payload ---" + +RESP="$(curl -sS -H "Authorization: Bearer $ADMIN_SECRET" \ + "http://127.0.0.1:$BACKEND_PORT/admin/context/$ID/payload")" +echo "$RESP" | head -c 400; echo "…" + +VERIFIED="$(echo "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["verified"])')" +if [ "$VERIFIED" != "True" ]; then + echo "FAIL: payload hash did NOT verify" + echo "$RESP" + exit 1 +fi +echo "--- PASS: payload verified end-to-end against real Tbox ---" diff --git a/wiki/admin-script.md b/wiki/admin-script.md index d0bac27..ae1a523 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -1,51 +1,125 @@ # Admin script -The TT-side command-line tool. Talks directly to the backend's Postgres -(via the same SQLModel schema) for user / course / token management, -and pulls blobs from jbox for inspection via a locally-running Tbox -WebDAV endpoint bound to the TT's own jaccount. - -The caller's authority is scoped to the courses where they hold -`role = admin` in `enrollments`. All commands filter by that scope; an -admin in CS101 cannot list / fetch records or issue tokens for CS201. -Identity is read from `$AIMDWARE_ADMIN_JACCOUNT` (or a `--as ` -flag). This is soft enforcement — raw SQL bypasses it. - -## Tech stack - -- Python 3.12, same SQLModel schema as the backend -- Shipped as a Python package in the same repo -- jbox access: TT runs [Tbox](https://github.com/1357310795/TboxWebdav) - locally (same as the student-side setup, just authenticated to the - TT's jaccount). `records fetch` shells out to `rclone` against the - Tbox WebDAV endpoint — e.g. `rclone copy tbox:/aimdware//.json ./`. - Student-side permission grants on those paths are handled out-of-band. +`aimdware-admin` — the TT-side CLI. Talks directly to the backend's +database (same SQLModel schema) for user / course / token management, +and pulls blobs from jbox via a locally-running Tbox WebDAV endpoint +(bound to the TT's own jaccount) for inspection. + +The CLI ships inside the `aimdware-backend` Python package and is +exposed as a `project.scripts` entry point, so it's installed alongside +the backend itself: + +```bash +AIMDWARE_DATABASE_URL=postgresql://... \ + uv run aimdware-admin ... +``` + +v1 has **no in-CLI access control** — anyone who can run the script +can do anything. Authority is enforced by who has DB / shell access on +the backend host. Course-scoped admin role enforcement is deferred. ## Commands ``` -aimdware-admin user create --jaccount zhangsan --email z@sjtu.edu.cn --display "Zhang San" +aimdware-admin user create --jaccount alice --email a@sjtu.edu.cn --name "Alice Liu" +aimdware-admin user create --jaccount alice --name "Alice Liu" --student-id 5190100001 # email derived +aimdware-admin user list + aimdware-admin course create --code ECE4721J --title "Intro to Systems" --semester 2026-spring -aimdware-admin enrol --course ECE4721J --user zhangsan --role student # or --role admin -aimdware-admin enrol-bulk --course ECE4721J --csv roster.csv -aimdware-admin token issue --course ECE4721J --user zhangsan -aimdware-admin token revoke --course ECE4721J --user zhangsan -aimdware-admin records list --course ECE4721J [--student zhangsan] [--since 2026-04-01] -aimdware-admin records show --id -aimdware-admin records fetch --id [--verify] +aimdware-admin course list + +aimdware-admin enroll --user alice --course ECE4721J [--role student|admin] + +aimdware-admin token issue --user alice # revokes any prior active token + issues new +aimdware-admin token revoke --prefix st_K9aB6r # 8-char prefix is shown when issued +aimdware-admin token list [--user alice] + +aimdware-admin record list [--course X] [--user X] [--assignment X] [--status pending|uploaded|...] [--limit N] +aimdware-admin record payload --id # fetch blob from Tbox + verify sha256 +``` + +### Batch over a roster CSV + +`user create`, `enroll`, `token issue`, and `token revoke` accept `--csv` +to operate over a whole roster in one go. The CSV is `名字,学号,jaccount` +(name, student_id, jaccount) in UTF-8; a header row and blank lines are +skipped, and only `jaccount` is strictly required per row. + +``` +aimdware-admin user create --csv roster.csv [--email-domain sjtu.edu.cn] +aimdware-admin enroll --csv roster.csv --course ECE4721J [--role student] +aimdware-admin token issue --csv roster.csv # prints jaccount + plaintext + prefix per row +aimdware-admin token revoke --csv roster.csv # revokes ALL active tokens per jaccount +``` + +`user create --csv` derives each email as `@` (default +`sjtu.edu.cn`, override with `--email-domain`) and stores `学号` in the +user's `student_id`. Batch commands process rows independently, continue +past per-row failures, print a JSON array of `{jaccount, status, …}` +(status ∈ created/exists/enrolled/issued/revoked/error), and exit +non-zero if any row errored — so a wrapper script can detect partial +failure. Typical token rollout: + +```bash +aimdware-admin user create --csv roster.csv +aimdware-admin enroll --csv roster.csv --course ECE4721J +aimdware-admin token issue --csv roster.csv > tokens.json # distribute per jaccount ``` -- `token issue` prints the plaintext course token once; hand it directly - to the student. -- `records fetch` is the only command that touches jbox. Uses the TT's - own jaccount; the backend never holds a jbox credential in v1. -- `--verify` recomputes sha256 over the fetched blob and writes - `blob_status = verified | tampered | missing` back to the row. +All commands print JSON to stdout (newline-indented) so scripts can +pipe through `jq`. `token issue` is the **only place the plaintext is +ever observable** — capture it immediately and hand it to the student +through your channel of choice. -## Project layout +## Token lifecycle + +Backend stores `sha256(plaintext)`; the student's router config is the +only place the plaintext lives long-term. ``` -src/aimdware_admin/ - cli.py - jbox_inspect.py +issue: + plaintext = "st_" + secrets.token_urlsafe(32) + hash = sha256(plaintext) + prefix = plaintext[:8] # human ID, e.g. "st_K9aB6r" + + transaction: + set revoked_at on any active token for this user + INSERT INTO student_tokens (user_id, token_hash, prefix, created_at) + VALUES (uid, hash, prefix, now) + + print(plaintext) # ONLY time plaintext is observable + +revoke: + UPDATE student_tokens SET revoked_at = NOW() + WHERE prefix = ? AND revoked_at IS NULL + +rotate (= issue again): + atomic { revoke active; insert new } + print new plaintext ``` + +If the student loses the plaintext, `token issue` again is the only +path — we cannot recover the old one. + +## record payload + +`aimdware-admin record payload --id ` is the same logic as the +admin HTTP endpoint `GET /admin/context//payload`: it pulls the +blob from the configured Tbox endpoint (`AIMDWARE_TBOX_URL`, +`AIMDWARE_TBOX_USER`, `AIMDWARE_TBOX_PASS` env vars) and recomputes +sha256 against the stored `blob_hash`. Returns a `verified` flag plus +the UTF-8-decoded payload so the TT can `jq '.request.messages'` it +directly. + +The CLI does **not** write `blob_status = verified | tampered | missing` +back to the DB. That's an operator-driven action and we want it in a +separate command (TODO). + +## Deferred + +- Course-scoped admin authority (filter commands by `--as `) +- `record verify` that updates `blob_status` after fetching +- Audit log of who ran which command when + +(Roster CSV batch — `user create` / `enroll` / `token issue` / `token revoke` +with `--csv` — is now implemented; see "Batch over a roster CSV" above.) diff --git a/wiki/architecture.md b/wiki/architecture.md index 302c1f5..d6affcc 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -2,91 +2,114 @@ ## System overview -v1 ships three components: a **backend** (Python/FastAPI/Postgres), an -**LLM client** (Bun single binary on the student's machine), and an -**admin script** (`aimdware-admin` Python CLI for the TT). Two roles: -**student** and **admin** (a.k.a. TT). Admin authority is scoped per -course via `enrollments` — being an admin in CS101 grants no access to -CS201's data. +v1 ships three components: a **backend** (Python/FastAPI), a **router** +(Bun single-binary on the student's machine), and an **admin CLI** +(`aimdware-admin`, ships inside the backend package). Two roles: +**student** and **admin** (TT). ``` - ┌───────────────────────┐ - │ TT (admin) │ ──── aimdware-admin CLI - └───────────────────────┘ (direct Postgres) + - jbox via own jaccount - ┌───────────────────────┐ - │ Student │ ──── upload via router - └───────────────────────┘ - ▼ - ┌──────────────────────────┐ ┌───────────────┐ - │ Backend (ingest only) │◀─────│ Client router │ - │ metadata + hash only │ │ student's │ - └──────────────────────────┘ │ localhost │ - └───┬───────┬───┘ - │ │ - blob │ │ chat - (JSON) │ │ completion - ▼ ▼ - ┌──────────────────────┐ ┌──────────────┐ - │ Student's jbox │ │ Upstream LLM │ - │ (1 TB/student quota) │ │ (OpenAI etc.)│ - └──────────────────────┘ └──────────────┘ + TT ─── aimdware-admin ────► backend DB (user/course/token mgmt) + └── /admin/* HTTP ─────► backend ─── WebDAV ─► student's jbox (audit reads) + + Student ┌──────────────────┐ + (local) ◄── /v1/chat ──►│ router (binary) │── /v1/chat ──► upstream LLM + │ │── /ingest/* ──► backend + │ │── PUT ──► student's WebDAV + └──────────────────┘ (jbox / nextcloud / + minio / any compliant) ``` The router is the only piece that sees the student's LLM credential. -The backend stores no payload content — only metadata, hash, and the -jbox URI. +The backend stores **no payload content** — only metadata, hash, and +the WebDAV URI. ## Components -**Backend.** Postgres + FastAPI. Exposes `/ingest/*` only (course-token -auth). +**Backend.** SQLite (dev) / Postgres (prod) + FastAPI. Two HTTP +surfaces: `/ingest/*` (student-token auth) and `/admin/*` (shared-secret +auth). The admin endpoints proxy reads from the student's WebDAV using +a backend-side reader account (in production this account would have +read scope on student folders; pre-prod uses the same admin/admin +local Tbox). -**LLM client.** Single binary on the student's machine. Exposes an -OpenAI-compatible Chat Completions endpoint on `localhost`, forwards to -a student-configured upstream with the student's LLM key, sends -metadata + sha256 to the backend, and uploads the response JSON to the -student's jbox via `rclone` against a locally-running Tbox WebDAV -endpoint. +**Router.** Single Bun binary on the student's machine. Listens for +OpenAI Chat Completions at `localhost`, forwards to a +student-configured upstream with the student's LLM key, classifies +each request into a session (prefix-extension match), captures the +full request body + response into a per-session blob, and asynchronously +posts metadata to the backend + PUTs the blob to the student's WebDAV. -**Admin script.** TT-side Python CLI (`aimdware-admin`). Manages users -/ courses / enrollments / tokens by talking directly to Postgres; -fetches blobs from jbox for inspection. +**Admin CLI.** TT-side Python CLI (`aimdware-admin`). Manages users / +courses / enrollments / tokens by talking directly to the backend DB; +fetches blobs from WebDAV by calling `/admin/.../payload` over HTTP. ## Credentials -| Credential | Held by | Used for | -| ------------------- | ------- | ----------------------------- | -| Course token | student | router -> backend ingest auth | -| Student LLM API key | student | router -> upstream LLM | +| Credential | Held by | Used for | +|---|---|---| +| `student_token` | student (router config, mode 600) | router → backend `/ingest/*` | +| upstream LLM api_key | same | router → upstream chat completions | +| WebDAV user/pass | same | router → student's jbox (PUT) | +| `AIMDWARE_ADMIN_SECRET` | backend host env | TT-tooling → backend `/admin/*` | +| WebDAV reader account | backend host env | backend → WebDAV (audit reads) | + +One `student_token` per student; the course context is sent in each +ingest request body (`course_code` + `assignment`). The backend +verifies the student is enrolled in that course before recording. +Rotating the token revokes all further uploads for the student across +every course; LLM provider and WebDAV credentials are the student's +own to rotate. -The router holds no jbox secret — auth lives in the student's locally -running Tbox (WebDAV gateway to jbox), already bound to their jaccount. -The backend holds no jbox credential in v1. Course tokens are -per-`(student, course)`; rotating one disables further uploads for that -pair without affecting LLM access. +## Storage split + +- **Backend DB**: metadata + hash + URI only. ~500 B per row. 20 + courses × 100 active students × 50 req/day × 100 days ≈ 5 GB / sem. +- **Student's WebDAV** (jbox or similar): full conversation blobs as + pretty-printed JSON. Path: + `/aimdware///.json`. **One file per + session** (Design A): a 50-turn agent conversation produces one file + that grows monotonically with the conversation, not 50 files. See + [llm-client.md → "Session identification"](llm-client.md). +- **Tamper detection** by sha256, recorded at capture and re-verified + on demand via `/admin/context//payload`. ## Roles and RBAC -`enrollments(user_id, course_id, role)` with `role ∈ {student, admin}`. -An admin enrollment grants TT-level access **only to that course**; -there is no global admin flag. v1 enforcement is soft (the admin -script filters operations by the caller's admin enrollments); raw SQL -bypasses it. +`enrollments(user_id, course_id, role)` with `role` in `{student, admin}`. +v1 enforcement is **soft and operational** — `/admin/*` is gated by a +shared secret, not by per-user authorization. Anyone with the admin +secret can read every course. Per-course admin scoping is a documented +TODO; see [admin-script.md](admin-script.md). -## Storage split +## Tech stack -- Postgres: metadata + hash + URI only. ~500 B per row; for 20 courses - × 100 active students × 50 req/day × 100 days ≈ 5 GB / semester. -- jbox (per student): full JSON payloads, addressed by record id. -- Tamper detection by hash. +| Component | Stack | +|---|---| +| Backend | Python 3.13 + FastAPI + SQLModel + Alembic + sqlite/Postgres | +| Router | Bun (compiled single binary per OS) | +| Admin CLI | Python, ships with backend package | -## Tech stack +Wire format is OpenAI Chat Completions only. Subscription auth (Codex, +Copilot) and Anthropic-format inbound are not in scope for v1. + +## Key design choices + +**Session-keyed blobs (Design A)** — one jbox file per logical +session, overwritten on each turn. Avoids O(N²) storage for multi-turn +conversations. See [llm-client.md](llm-client.md) for the matching +algorithm. + +**Atomic single-flight in the outbox** — `UPDATE … RETURNING` lets +multiple worker processes share one `queue.db` without double-uploads. + +**The router persists `request` and `response` verbatim** — not a +hand-picked subset. Anything the model saw (system prompt, messages, +tools, sampling params, vendor-specific fields) is in the blob. New +OpenAI parameters don't need a code change in the router. -| Component | Stack | -| --------- | ----------------------------------------------------- | -| Backend | Python 3.12 + FastAPI + SQLModel + Alembic + Postgres | -| Client | Bun (compiled single binary) | +**WebDAV-agnostic** — the router speaks standard PUT + MKCOL + +PROPFIND. jbox via Tbox is the reference setup; any compliant WebDAV +works. -Wire format is OpenAI Chat Completions only. Subscription auth and -Anthropic-format inbound are not in scope. +See [design-notes.md](design-notes.md) for what we learned running +this against real agent platforms (opencode + plugins). diff --git a/wiki/backend.md b/wiki/backend.md index cb0bdaa..4af3c33 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -1,138 +1,203 @@ # Backend -- Python 3.12 + FastAPI + SQLModel + Alembic + PostgreSQL -- Auth: course-token bearer for `/ingest/*`. No sessions, no password - hashing in v1. +- Python 3.13 + FastAPI + SQLModel + Alembic + SQLite (dev) / Postgres (prod) +- Auth: student-token bearer for `/ingest/*`; shared-secret admin bearer + for `/admin/*`. No sessions, no password hashing. -## Ingest API - -Caller: student router. Auth: course token in `Authorization: Bearer ct_...`. -Write-only — no endpoint returns content. - -| Method | Path | Function | -| ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/ingest/health` | Unauthenticated liveness probe. | -| `POST` | `/ingest/context` | Record one entry. Body: `{ record_id, blob_hash, blob_uri, blob_size, model, prompt_tokens, completion_tokens, ts, router_version, client_meta }`. Resolves `(student, course)` from the token, inserts `ContextRecord` with `blob_status = pending`. **Idempotent on `record_id`**: a repeat POST with the same id returns `200` with the existing row (no double insert), provided the body matches; mismatched body → `409 Conflict`. New inserts return `202`. | -| `POST` | `/ingest/context/{record_id}/uploaded` | Router calls this after `rclone copy` to the Tbox WebDAV endpoint reports success. Transitions `blob_status` to `uploaded`. Idempotent. | - -Blobs never traverse this surface — only the hash, URI, and metadata. - -## Datamodel - -SQLModel. The full schema is created at v1 time so later HTTP surfaces -can be bolted on without migrations. - -```python -from datetime import datetime -from enum import Enum -from typing import Optional -from uuid import UUID, uuid4 - -from sqlalchemy import BigInteger, Column, Index, JSON, LargeBinary -from sqlmodel import Field, SQLModel +## Surfaces +``` +/ingest/* student router → here. metadata + hash only, no blob bytes. +/admin/* TT tooling → here. read-only audit. shared-secret auth. +``` -class Role(str, Enum): - student = "student" - admin = "admin" # TT — scoped to the courses where this row exists +### Ingest API + +Caller: student router. Auth: student token in `Authorization: Bearer st_...`. + +| Method | Path | Function | +|---|---|---| +| `GET` | `/ingest/health` | Unauthenticated liveness. | +| `POST` | `/ingest/context` | Record one entry (body below). Idempotent on `record_id`: replay-same-body returns 200; replay-different-body returns 409. Fresh insert returns 202. **Also** returns 409 if `(session_id, turn_count)` already exists with a different `record_id` (UNIQUE DB constraint). Returns 403 if the student isn't enrolled as a student in `course_code`. | +| `POST` | `/ingest/context/{record_id}/uploaded` | Mark blob_status uploaded once the router confirms the WebDAV PUT. Idempotent. Scoped to the owning student. | + +Body of `POST /ingest/context`: + +```jsonc +{ + "record_id": "", + "session_id": "", + "turn_count": 1, + "course_code": "ECE4721J", + "assignment": "hw1", + "blob_hash": "", + "blob_uri": "aimdware/ECE4721J/hw1/.json", + "blob_size": 1234, + "model": "deepseek-chat", // optional + "prompt_tokens": 0, // optional + "completion_tokens": 0, // optional + "ts": "2026-05-15T...", + "router_version": "0.1.0", + "client_meta": { "upstream_type": "openai" } +} +``` +`blob_hash` is sha256 of the **blob file the router PUTs to WebDAV**, +not of the upstream's response body. The backend never sees blob bytes +in this surface; it only checks the hash later via the admin endpoints. + +### Admin API + +Caller: TT tooling (`aimdware-admin` CLI, or anything with the admin +secret). Auth: `Authorization: Bearer `. Returns +503 if `AIMDWARE_ADMIN_SECRET` is unset. + +| Method | Path | Function | +|---|---|---| +| `GET` | `/admin/context/{record_id}/payload` | Fetch the session blob from WebDAV, recompute sha256, return `verified` flag + the parsed payload. **For multi-turn sessions** the on-jbox blob is the latest turn's state; an older `record_id`'s hash will NOT match the current blob (the field `is_latest_turn` flags this). Use `/admin/session//payload` for canonical session-level verification. | +| `GET` | `/admin/session/{session_id}/payload` | Same fetch but verifies against the LATEST turn of that session, which is what's actually on jbox. | + +Response shape (both endpoints): + +```jsonc +{ + "record_id": "...", // omitted on session endpoint + "session_id": "...", + "turn_count": N, + "is_latest_turn": true, // only on /admin/context//payload + "blob_uri": "...", + "blob_size_stored": N, + "blob_size_actual": N, + "blob_hash_stored": "", + "blob_hash_actual": "", + "verified": true, + "payload_utf8": "" +} +``` -class BlobStatus(str, Enum): - pending = "pending" # hash recorded; upload not confirmed - uploaded = "uploaded" # router confirmed rclone push succeeded - verified = "verified" # backend verified hash (post-v1) - tampered = "tampered" # hash mismatch (post-v1) - missing = "missing" # blob not found in jbox (post-v1) +## Data model +SQLModel-declared, Alembic-migrated. Schema diagram: -class User(SQLModel, table=True): - __tablename__ = "users" +``` +users(id, jaccount UNIQUE, email UNIQUE, display_name, + student_id nullable, roster 学号; not unique (blanks/dupes ok) + is_active, ts) + ▲ + │ + ├── enrollments(user_id PK, course_id PK, role) + │ role ∈ {student, admin} + │ + ├── student_tokens(id, user_id, token_hash bytes, prefix, ts, revoked_at) + │ UNIQUE INDEX (user_id) WHERE revoked_at IS NULL + │ -- at most one active token per user + │ + └── context_records( + id PK, router-generated UUID (= record_id) + user_id FK users, + course_id FK courses, + assignment indexed, free-form course-scoped label + session_id indexed, identifies the shared jbox blob + turn_count, 1-based, unique within a session + ts indexed, + model, prompt_tokens, completion_tokens, + router_version, client_meta, + blob_uri, blob_hash bytes, blob_size, + blob_status indexed, pending|uploaded|verified|tampered|missing + blob_verified_at, + UNIQUE (session_id, turn_count) + ) + +courses(id, code UNIQUE, title, semester, ts) +``` - id: UUID = Field(default_factory=uuid4, primary_key=True) - display_name: str - email: str = Field(unique=True, index=True) - jaccount: str = Field(unique=True, index=True) - is_active: bool = Field(default=True) - created_at: datetime = Field(default_factory=datetime.utcnow) +### Why `session_id` is indexed but not foreign-keyed +We don't model `Session` as a first-class entity. The session_id is +generated by the router (in-memory UUID), shared by every record of a +multi-turn conversation, and used only for grouping on the audit side. +No relational integrity to enforce. -class Course(SQLModel, table=True): - __tablename__ = "courses" +### Why `assignment` is a string, not a FK - id: UUID = Field(default_factory=uuid4, primary_key=True) - code: str = Field(unique=True, index=True) - title: str - semester: str - created_at: datetime = Field(default_factory=datetime.utcnow) +Assignments are TT-decreed slugs (homework slug, lab number, exam name; +letters, digits, underscore, dot, and hyphen). Modelling them as entities would force admin overhead +("create assignment row before students can use it") for no gain — TT +audit is purely string-equality on this field. +### BlobStatus semantics under multi-turn -class Enrollment(SQLModel, table=True): - __tablename__ = "enrollments" +``` +pending after POST /ingest/context, before /uploaded +uploaded router confirmed WebDAV PUT +verified TT ran a manual hash check via admin endpoint AND it matched +tampered same, but hash mismatched +missing same, but blob not found on WebDAV +``` - user_id: UUID = Field(foreign_key="users.id", primary_key=True) - course_id: UUID = Field(foreign_key="courses.id", primary_key=True) - role: Role - created_at: datetime = Field(default_factory=datetime.utcnow) +**Important for multi-turn:** `uploaded` on a record means "the router +successfully PUT a snapshot at the time of that turn". It does NOT +mean "this record's blob_hash matches what's currently on jbox" — +because subsequent turns OVERWRITE the jbox file. Only the latest +turn's hash matches at any given moment. The `is_latest_turn` flag on +the admin endpoint surfaces this. +## Token validation -class CourseToken(SQLModel, table=True): - __tablename__ = "course_tokens" +Plaintext lives only on the student's machine. Backend stores +`sha256(token)`. Per request: - id: UUID = Field(default_factory=uuid4, primary_key=True) - user_id: UUID = Field(foreign_key="users.id", index=True) - course_id: UUID = Field(foreign_key="courses.id", index=True) - token_hash: bytes = Field(sa_column=Column(LargeBinary)) - prefix: str - created_at: datetime = Field(default_factory=datetime.utcnow) - revoked_at: Optional[datetime] = None +``` +1. plaintext = Authorization header (Bearer) +2. digest = sha256(plaintext) +3. row = SELECT user_id FROM student_tokens + WHERE token_hash = digest AND revoked_at IS NULL +4. if not row: return 401 +5. inject user_id into request context +``` - # Partial unique index on (user_id, course_id) WHERE revoked_at IS NULL - # is created in the Alembic migration. - __table_args__ = (Index("ix_course_tokens_user_course", "user_id", "course_id"),) +Implementation: +- DB equality on a fixed-length `bytea` is constant-time in practice. +- `Authorization` header is on the logger's redact list. +- Plain sha256, not argon2/bcrypt. Tokens are 256-bit random secrets; + slow hashing protects passwords, not high-entropy random strings. -class ContextRecord(SQLModel, table=True): - __tablename__ = "context_records" +## Migrations - id: UUID = Field(default_factory=uuid4, primary_key=True) - user_id: UUID = Field(foreign_key="users.id", index=True) - course_id: UUID = Field(foreign_key="courses.id", index=True) - ts: datetime = Field(default_factory=datetime.utcnow, index=True) - model: str - prompt_tokens: int = 0 - completion_tokens: int = 0 - router_version: str - client_meta: dict = Field(default_factory=dict, sa_column=Column(JSON)) +Production schema is owned by Alembic, not `SQLModel.metadata.create_all`: - blob_uri: str - blob_hash: bytes = Field(sa_column=Column(LargeBinary)) - blob_size: int = Field(sa_column=Column(BigInteger)) - blob_status: BlobStatus = Field(default=BlobStatus.pending, index=True) - blob_verified_at: Optional[datetime] = None +``` +backend/alembic/versions/ + ad7b66d6bff9_0001_initial_schema.py full v1 schema + partial unique index + 1cc659f78871_0002_unique_session_turn.py UNIQUE(session_id, turn_count) + b984da6ac5c5_0003_add_assignment...py ADD COLUMN assignment + index + c0a1d2e3f4b5_0004_add_student_id.py ADD COLUMN users.student_id (nullable) ``` -Notes: - -- `CourseToken` rotation inserts a new row and sets `revoked_at` on - the old. The partial unique index enforces one active token per - (student, course). -- `BlobStatus` reaches `uploaded` from the router's own POST. The - TT's `records fetch --verify` writes `verified` / `tampered` / - `missing` when they inspect a record. -- `ContextRecord.id` is the router-generated idempotency key. The PK - uniqueness on `id` plus the API's "match existing row → 200, mismatch - → 409" semantics make `POST /ingest/context` safely retryable. -- `blob_size` uses `BIGINT` (not `INTEGER`) — INTEGER tops out at 2 GB - and runaway blobs should fail loudly, not silently truncate. - -## Security - -- **Course token scoping.** The token encodes `(student, course)` via - its row; the router cannot influence attribution by tampering with - the request body. -- **Write-only ingest.** No HTTP read surface, so a stolen course - token writes at most fake records for one student-course. -- **No content on the backend.** Postgres holds metadata + hash + URI. - A full DB compromise yields no student work. -- **Constant-time compare** on course-token validation; tokens never - in logs. +The admin CLI refuses to run unless the DB is at the latest revision +(`c0a1d2e3f4b5`), so after pulling new code run `uv run alembic upgrade head` +**before** restarting the backend — the `User` model references `student_id`, +which a not-yet-migrated DB lacks. + +Apply: `AIMDWARE_DATABASE_URL=... uv run alembic upgrade head`. + +Tests use `create_all` for speed, plus a fixture in `conftest.py` that +manually mirrors the partial unique index (which SQLModel can't +express). + +## Security recap + +- **Token → student attribution.** The token identifies the student; + router cannot impersonate someone else by body-tampering. +- **Write-only ingest from the student side.** A stolen student token + can write fake records for their enrolled courses; it cannot read. +- **No blob bytes on backend.** Postgres holds metadata + hash + URI + only. A full DB compromise yields no student work — the work is + on jbox accounts the backend can't decrypt. +- **DB compromise leaks no usable token.** Only `sha256(token)`. + Rotation is the response (see `aimdware-admin token issue`). +- **Admin endpoints are gated by a shared secret** (`AIMDWARE_ADMIN_SECRET`). + Leaks of that secret give read-only audit access. The backend still + needs WebDAV creds to actually fetch blobs — those live in + `AIMDWARE_TBOX_USER`/`AIMDWARE_TBOX_PASS`. diff --git a/wiki/design-notes.md b/wiki/design-notes.md new file mode 100644 index 0000000..3ae0901 --- /dev/null +++ b/wiki/design-notes.md @@ -0,0 +1,183 @@ +# Design notes + +Empirical findings from running the router against real LLM upstreams +(SJTU's models.sjtu.edu.cn) driven by real agent platforms (opencode +1.3.x + oh-my-opencode plugins). The architecture works; what these +notes capture is **what real-world load looks like** so a future +maintainer doesn't relitigate decisions from first principles. + +## Session-keyed blobs (Design A) + +**Problem we solved.** OpenAI Chat Completions is stateless. Multi-turn +chat re-sends the **full history** on every request. Naive +per-HTTP-call storage of the captured blob produces O(N²) total bytes +for an N-turn conversation. A 50-turn coding agent with 100 KB +messages each blows up to ~250 MB on jbox. + +**What we do instead.** SessionTracker keeps an in-memory LRU of +"active sessions" (up to 32). On each capture: + +1. Parse `request.messages` from the captured bytes. +2. For each active session, check whether the new `messages` is a + **strict prefix-extension** of that session's last seen tip. +3. Match → same `session_id`, `turn_count++`, overwrite the existing + jbox file with the new (larger) state. No match → new UUID, + fresh session of 1 turn. + +Result: 50 turns of one conversation → 1 jbox file → O(N) storage. + +**Comparison key.** `messagesEqual` runs a recursive `canonicalize` +(sort object keys at every nesting level) before stringifying. A +client that re-orders `tool_calls` keys between turns still merges +correctly. + +**What this does NOT merge.** When an agent orchestrator spawns +sub-agents with DIFFERENT system prompts (see below), each sub-agent's +calls form their own session. The merging is correct at the +semantic level; what would be wrong is forcing them into the same +session_id. + +## Agent platform overhead + +A "simple" opencode user task on this stack produces **15-30 jbox +files**, not 1. Empirically, for one prompt "Write a Python one-liner +that reverses a string": + +| count | what it is | +|---|---| +| ~7 | Sisyphus main agent (the actual task runner) | +| ~6 | "summarizer" sub-threads ("What did we do so far?") | +| 1 | thread title generator | +| ... | per-task housekeeping | + +These are **legitimately distinct conversations** — each has its own +system prompt + opening user message, so SessionTracker correctly +treats them as separate sessions. Trying to merge them by some heuristic +would lose semantic information (was this the user's task or a +title-generation sub-task?). + +**TT-side implication.** "All the records from one student's `opencode +run` for assignment X" is **not** a single session_id query — it's a +time-window query on (user, course, assignment). The session_id +groups one logical conversation; one user task can produce many. + +**For audit:** filter by `messages[0]` system-prompt hash to separate +"main thread" from "housekeeping threads" (title gen, summarizer). + +## Compression behavior + +When a client's context approaches the model's window, the client may +**compact** earlier turns into a summary. The next request's `messages` +is shorter than the prior tip and has different content at older +positions. This violates the strict-prefix invariant → SessionTracker +classifies it as a new session. + +This is the correct behavior — the compacted conversation is +semantically different from what came before (older turns are now +summary placeholders). Audit needs to know that a session ended and a +new one began at compression time. We surface this naturally as a +session_id transition. + +## What the router does NOT see + +The router is a **MITM proxy at the LLM wire**. It captures everything +sent to or received from the LLM upstream. It **does not see**: + +- Client-side internal tool calls that don't go through the LLM. If + opencode's Read tool slurps a file into context, the router only + sees the bytes that subsequently reach the LLM. If opencode decides + to refuse the file (e.g., image attachments to a "non-vision" + provider), the bytes never reach the LLM and the router has no record. +- LLM responses the client consumes but doesn't act on. (The router + captures the full response stream, so this is rare.) +- Calls made through a different unmonitored client (vanilla `curl`, + another `openai-python` binary the student installs side-by-side). + +In our Test 2 (multimodal): opencode's Read tool intercepted an image +attachment, decided the configured provider didn't support vision, +substituted an error message into the prompt, and the LLM saw zero +image bytes. Router captured exactly what was sent. The image bytes +**never crossed the router**. + +This is consistent with the [threat-model.md](threat-model.md): the +router provides visibility for compliant use, not enforcement against +adversarial clients. + +## Multimodal: confirmed working through the router + +Direct OpenAI-spec multimodal (`content: [{type: text}, {type: +image_url}]`) flows through the router with **byte-level fidelity**. +A 68-byte transparent PNG embedded as `data:image/png;base64,...` in +a `image_url` part round-tripped: SHA matched after we +`base64 -d`'d the blob's `image_url` content and compared to the +original file. + +So: +- TT can recover any image the student showed the model by extracting + `image_url` parts from the blob. +- Students using opencode's `-f file.png` flag for image attachments + do NOT actually send the image (opencode strips it for unknown- + capability providers). They have to configure provider capabilities + in opencode.json, or use a non-opencode client, for the image to + reach the LLM. + +## The "tools" field is captured (post-Test 4) + +Originally `buildSessionBlob` hand-picked fields off the request +(`model`, `messages`). Test 4 surfaced a 100 KB delta: opencode + +plugins + MCP advertise ~75 tools to the model in the request's +`tools` array, totalling ~25k tokens. We were silently dropping all +of it. + +Fixed in commit `509a670`, then generalised in commit `42d4eed`: the +blob now carries the **entire parsed request body** under `request`. +Anything the model saw is preserved. + +## Audit playbook + +How a TT actually finds what student X did for assignment Y: + +```bash +# 1) Token / DB-level work +aimdware-admin record list --user X --assignment Y +# returns rows sorted by ts, with session_id grouping visible + +# 2) Group by session for the readable view +SESSIONS=$(... | jq -r .session_id | sort -u) +for sid in $SESSIONS; do + aimdware-admin record payload --id + # or via HTTP: GET /admin/session/$sid/payload +done + +# 3) Filter to "main" conversations (drop title-gen / summarizer noise) +# by hashing messages[0] and grouping +... | jq -r '.payload | fromjson | .request.messages[0].content | @base64' | sort | uniq -c +``` + +For "did this student show the model an image": + +```bash +... | jq '.payload | fromjson | .request.messages[] + | (.content // []) | if type=="array" then + map(select(.type=="image_url")) else [] end + | length' | grep -v '^0$' +``` + +For "did this student have filesystem access tools": + +```bash +... | jq '.payload | fromjson | .request.tools // [] | map(.function.name)' +``` + +## Things explicitly deferred + +- **Per-course admin authorization in `/admin/*`.** v1 is a shared + secret with global read. +- **Session reconstruction across opencode-style orchestrators.** The + data is captured; UI-level "show me everything for one user task" + needs heuristics we haven't built. +- **Backend timeout on the WebDAV reader.** A hung jbox endpoint + would hold one FastAPI worker indefinitely. Wrap with a timeout. +- **Router timeout on upstream calls.** Worker can stall on a hung + HTTP connection; another worker takes the row at the stale-claim + cutoff but the original worker leaks. Bound with AbortController. diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 6fd481f..3c67a03 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -1,29 +1,127 @@ # LLM client -Single package on the student's machine. Sits between the coding agent -and the upstream LLM. After each request: +Single binary on the student's machine. Sits between the coding agent +and an upstream that may speak either OpenAI Chat Completions or OpenAI +Responses. Three things happen per model call, all off the student's +critical path: -- **metadata + hash** → POST to backend (`/ingest/context`) -- **full prompt + response JSON** → uploaded to the student's jbox via - `rclone` shelling out against a locally-running Tbox WebDAV endpoint - (Tbox wraps jbox in WebDAV using the student's jaccount) - -Both happen in parallel, both off the student's critical path. +1. **forward** the request to upstream, stream the response back to the + client byte-for-byte; +2. **capture** the request bytes + response bytes into a session-keyed + blob on local disk; +3. **relay** the metadata to the backend over HTTP and the blob to a + WebDAV endpoint the student controls (jbox via Tbox by default, + but **any** WebDAV-compatible store works — see below). ## Config ```yaml -course_token: ct_... # TT hands it to the student directly +student_token: st_... # one per student; TT hands it directly +course: ECE4721J # course code slug; sent with every ingest call +assignment: hw1 # TT-decreed slug; A-Z/a-z/0-9/_.- +upstream: + plugin: openai # openai, codex, or copilot; `type` remains an alias + base_url: https://models.sjtu.edu.cn/api/v1 + api_key: sk-... # required only for openai-compatible API providers +port: 12345 # router listens here +local_cache_dir: ~/.cache/aimdware # outbox + blob cache +backend_url: https://aimdware.example.edu +# WebDAV target (NOT necessarily jbox — any compliant endpoint) +tbox_url: http://127.0.0.1:50471 +tbox_user: alice +tbox_pass: +# Optional: must match the canonical default = aimdware// +# jbox_remote_path: aimdware/ECE4721J/hw1 +``` + +For ChatGPT/Codex or GitHub Copilot subscription routing, log in once +and switch the plugin: + +```bash +aimdware-router --config ./aimdware.yaml auth login codex +aimdware-router --config ./aimdware.yaml auth login copilot +aimdware-router --config ./aimdware.yaml auth status +``` + +```yaml upstream: - base_url: https://api.openai.com # default; overridable - api_key: sk-... # student's own -port: 12345 # router listens here; coding agent points at it -local_cache_dir: ~/.cache/aimdware # router-owned buffer -jbox_remote_path: aimdware/ # target path inside jbox cloud -backend_url: https://aimdware.sjtu.edu # hardcoded per build / overridable via flag + plugin: codex # or copilot +``` + +The subscription tokens are stored in `local_cache_dir/auth/auth.json` +(a dedicated 0700 dir, file 0600), not in `aimdware.yaml`. + +## API surface + +The router exposes both modern OpenAI API shapes: + +```text +POST /v1/chat/completions +POST /v1/responses +``` + +`plugin: openai` forwards both paths to the configured `base_url`, so +OpenRouter/SJTU-style OpenAI-compatible gateways can use whichever API +they support. `plugin: codex` is a native Responses provider matching +opencode's Codex path, so clients should call `/v1/responses`; the +router deliberately does not send Chat Completions bodies to the Codex +Responses endpoint. + +### Codex (ChatGPT subscription) specifics + +The ChatGPT-account Codex backend (`chatgpt.com/backend-api/codex/responses`) +is strict about the request — a real coding agent (Codex CLI / opencode) +sends it correctly, but if you call `/v1/responses` by hand, note: + +- **model**: codex-only ids like `gpt-5-codex` / `gpt-5` are rejected for + ChatGPT accounts ("not supported when using Codex with a ChatGPT account"). + Use a model your account exposes (e.g. `gpt-5.5`). +- `input` must be a **list** of messages (not a bare string), `instructions` + is **required**, and `store` must be **`false`**. Missing any of these comes + back as a `{"detail": "..."}` 4xx from upstream (the router forwards it + verbatim and still captures the exchange). +- **Reaching OpenAI**: `auth login codex` and every refresh/request hit + `auth.openai.com` / `chatgpt.com`. Behind a proxy (common in CN), set + `HTTPS_PROXY` when running the router and the login — the router honors + `HTTPS_PROXY`/`NO_PROXY`. Keep the **plain-HTTP** backend/Tbox direct by + setting only `HTTPS_PROXY` (not `HTTP_PROXY`). + +### What the router holds and what it doesn't + +| Credential | Where | What it can do | +|---|---|---| +| `student_token` | `aimdware.yaml`, mode 600 | POST to backend `/ingest/*` | +| `upstream.api_key` | same file, only for `plugin: openai` | call the student's chosen LLM provider | +| subscription OAuth tokens | `local_cache_dir/auth/auth.json`, only for `plugin: codex/copilot` | call that student's subscription provider | +| `tbox_user`/`tbox_pass` | same file | PUT to the student's chosen WebDAV | +| **NOT held**: backend admin secret, TT credentials, other students' data | + +If `aimdware.yaml` leaks, the file-backed secrets in that YAML are +compromised. The backend can mint a new `student_token` via +`aimdware-admin token issue`; LLM provider, subscription OAuth, and +WebDAV credentials are the student's own to rotate. + +### Why "tbox_*" instead of "webdav_*" + +Historical: we developed against Tbox (a jbox WebDAV gateway). The +router is genuinely WebDAV-agnostic — point it at any endpoint that +speaks PUT + MKCOL and you're fine. NextCloud, minio + webdav frontend, +a self-hosted webdav-server, all work. The field names are stuck for +now; the docs are honest about the generality. + +## What lands on disk + +Three on-disk artifacts in `local_cache_dir`: + +``` +queue.db SQLite outbox (worker state) +queue.db-wal, queue.db-shm WAL files +records/.json per-session blob; overwritten each turn ``` -The router holds no jbox credential — auth lives inside Tbox. +The `records/` files are **session-keyed** (Design A). Each new turn of +a multi-turn conversation overwrites the same file with the updated +state. A 50-turn agent run produces **one** file, not 50. ## Output @@ -31,71 +129,209 @@ OpenAI-compatible Chat Completions at `http://127.0.0.1:`. Coding agent points its `base_url` here with any non-empty `api_key`. Loopback-only; no inbound auth. -## Auth and provider - -Check the impl of [opencode](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/provider/) - -## Sync engine - -The student runs two local processes: - -- **[Tbox](https://github.com/1357310795/TboxWebdav)** — exposes jbox as - a local WebDAV endpoint on the student's machine. Authenticates to - jbox with the student's jaccount; launched once at setup. -- **The router** — uses `rclone` as the transport, shelling out to the - `rclone` binary to push files against the Tbox WebDAV endpoint. - -The router holds no jbox secret; the credential lives inside Tbox. - -Sync engine behavior: - -- Each captured response is written atomically to - `local_cache_dir/{record_id}.json`. -- A worker watches the cache and invokes - `rclone copy {cache_file} tbox:{jbox_remote_path}/` per blob - (`tbox:` is the rclone remote pre-configured to point at the local - Tbox WebDAV endpoint). -- Per-blob state tracked on disk: `pending → uploading → synced → failed`. -- Exponential backoff on transient failures; persistent failures surfaced - on the router's status page. -- Already-`synced` blobs are never re-uploaded (delta-aware). -- Queue + state survive restarts. -- After backend confirms `uploaded` via `/ingest/context/{id}/uploaded`, - the local cache copy is eligible for eviction (default: 7-day grace, - hard cache-size cap with LRU eviction). - -## Request flow - -``` -coding agent router upstream LLM backend jbox - │ POST /chat │ │ │ │ - ├─────────────▶│ POST /chat (auth │ │ │ - │ │ rewritten) │ │ │ - │ ├──────────────────────▶│ │ │ - │ │ streaming SSE │ │ │ - │ relay SSE │ (parallel: JSON, │ │ │ - │◀─────────────│ sha256, local cache) │ │ │ - │ │ POST /ingest/context │ │ │ - │ ├──────────────────────────────────────▶ │ - │ │ 202 (pending) │ │ - │ │◀──────────────────────────────────────│ │ - │ │ (sync engine) rclone copy → tbox WebDAV │ - │ │ → jbox cloud │ - │ ├──────────────────────────────────────────────▶│ - │ │ │ synced │ - │ │◀──────────────────────────────────────────────│ - │ │ POST /ingest/context/{id}/uploaded │ │ - │ ├──────────────────────────────────────▶ │ - │ │ 202 (uploaded) │ │ - │ │◀──────────────────────────────────────│ │ -``` - -## Tech stack - -Bun, compiled to a single binary per OS. Vercel AI SDK / `openai-node` -cover most upstream work. - -## Distribution - -Pre-built binaries on Gitea releases (Linux x64, macOS x64+arm64, -Windows x64). Install via `curl install.aimdware.sjtu.edu | sh`. +## Capture pipeline + +``` +inbound POST /v1/chat/completions + │ + ▼ + handler.ts ── forward to upstream ──► response stream tee'd + │ │ + │ ├──► client (verbatim) + │ └──► capture buffer + │ + capture.ts: emit { request_bytes, response_bytes } + │ + session.ts: classify into a session via prefix-extension + │ (if next request's messages strictly extends prior tip + │ of session S → same session_id + turn_count++) + ▼ + session-blob.ts: build the blob JSON for jbox + │ + writeAtomic: /records/.json + │ + outbox.enqueue(record_id, session_id, turn_count, ...) + │ + ── HTTP returns to client ── +``` + +Capture never blocks the client response. Per-call latency added by +the router on the critical path is ~ms (one read + one buffer copy + +SHA stream). + +## Session identification + +SessionTracker (in `src/recording/session.ts`) treats two requests as +the **same session** iff the second's `messages` array is a strict +prefix-extension of the first's tip: + +``` +prior.tip: [system, user1, assistant1, user2] +next: [system, user1, assistant1, user2, assistant2, user3] ✓ extends +next: [system, user1, assistant1, user2-edited] ✗ different content at index 3 +next: [system, user1, assistant1] ✗ shorter +``` + +Comparison uses a recursive `canonicalize` (sort keys at every nesting +level, then JSON.stringify) so a client that re-orders message keys +between turns still merges to one session. + +**What this is good for**: a vanilla OpenAI SDK doing multi-turn chat — +N HTTP calls collapse to 1 jbox blob, O(N) storage instead of O(N²). + +**What this doesn't catch**: agent orchestrators (opencode/Sisyphus, +autogen, CrewAI, etc.) that spawn parallel sub-conversations with +different system prompts. Those are *legitimately* distinct sessions +and get their own blobs each. See [design-notes.md](design-notes.md). + +LRU capacity: 32 active sessions per router process. In-memory only — +restarting the router starts fresh. + +## Outbox + relay + +Outbox is a SQLite table (`outbox` in `queue.db`). Each captured turn +becomes one row keyed by `record_id`. Schema: + +``` +record_id PK one HTTP call = one row +session_id indexed; identifies the shared blob file +body_json the metadata to send to backend +state captured → ingested → synced → done | conflict | fatal +attempts retry counter +next_attempt_at exponential backoff +created_at +cache_evicted 0/1, set when records/.json gets reclaimed +claimed_at atomic claim for multi-worker safety +``` + +**State machine** (each transition is one HTTP call on success): + +``` +captured ── POST /ingest/context ──────────► ingested +ingested ── PUT / ────► synced +synced ── POST /ingest/context//uploaded ──► done +``` + +**Atomic claim**: `relay.ts`'s `runOnce` uses `UPDATE … RETURNING` to +claim a batch of N records in one SQL statement; two workers (even +across processes on the same `queue.db`) can't grab the same row. +Stale claims (held by a worker that crashed mid-process) become +re-claimable after 60s. + +**Retries**: per-stage exponential backoff: +`1s → 5s → 30s → 5m → 30m → 1h`. State stays in the queue across +router restarts. A 5-day backend outage produces no data loss. + +**Concurrency**: 4 in-process workers process the batch in parallel. +SQLite WAL + `busy_timeout = 5000` keeps it safe across multiple +router processes too. + +## Eviction + +Session-keyed blob cache is reclaimed immediately after WebDAV upload +once **no** record sharing that `session_id` remains in `captured` or +`ingested`. Those are the only states that still need to read +`records/.json`. The periodic TTL pass uses the same +condition as a fallback, with `ttlMs` defaulting to 24 hours. One delete +per session; all member records get `cache_evicted = 1`. If a same-session +capture is currently writing the local blob, both cleanup paths skip that +session and try again later. + +The queue row itself never deletes — it remains a per-record audit +trail on the student's disk. + +## Multi-target build + +`bun run build:all` produces five binaries (~95 MB each, Bun runtime +embedded): + +``` +dist/aimdware-router-macos-arm64 +dist/aimdware-router-macos-x64 +dist/aimdware-router-linux-arm64 +dist/aimdware-router-linux-x64 +dist/aimdware-router-windows-x64.exe +``` + +Student install path: download the binary for their platform, drop +`aimdware.yaml` next to it, `./aimdware-router --config aimdware.yaml`. + +## Request flow (with blob path) + +``` +coding agent router upstream LLM backend WebDAV (jbox) + │ POST /v1/chat │ │ │ │ + ├──────────────────▶│ │ │ │ + │ │ POST /v1/chat (key │ │ │ + │ │ rewritten) │ │ │ + │ ├──────────────────────▶│ │ │ + │ │ streaming SSE │ │ │ + │ relay SSE │ │ │ │ + │◀──────────────────│ │ │ │ + │ │ classify session, │ │ │ + │ │ write blob to cache │ │ │ + │ │ │ + │ │ POST /ingest/context │ │ │ + │ ├───────────────────────────────────────▶│ │ + │ │ 202 / 200 │ + │ │◀───────────────────────────────────────│ │ + │ │ PUT /aimdware///.json │ + │ ├──────────────────────────────────────────────────────────▶│ + │ │ 201 │ + │ │◀──────────────────────────────────────────────────────────│ + │ │ POST /ingest/context//uploaded │ + │ ├───────────────────────────────────────▶│ │ + │ │ 200 │ + │ │◀───────────────────────────────────────│ │ +``` + +## What's captured in the blob + +See the schema in [backend.md → "Admin payload endpoints"]. Source of +truth is `src/recording/session-blob.ts`: + +```jsonc +{ + // router metadata (NOT in the request body) + "session_id": "...", + "course": "ECE4721J", + "assignment": "hw1", + "started_at": "...", + "latest_ts": "...", + "turn_count": N, + "upstream": { "type": "openai" }, + "upstream_status": 200, + + // the entire request body the LLM saw, verbatim + "request": { + "model": "...", + "messages": [...], + "tools": [...], + "tool_choice": "...", + "temperature": ..., + "max_tokens": ..., + /* any other field the client sent */ + }, + + // the upstream response, parsed if JSON, raw SSE string if streaming + "response": { ... } +} +``` + +Sampling params (temperature, top_p, …), tools, response_format, +seed — anything the request carried — survives intact. If OpenAI adds +a new parameter tomorrow, the router captures it without a code +change. + +## Tested with 1-5 MB payloads + +`src/recording/large-payload.test.ts` pins: + +- 1 MB user message → blob preserves verbatim, sha256 valid, <500 ms +- 1 MB tools array → all 200 schemas round-trip +- 1 MB SSE response → raw string preserved including `[DONE]` +- SessionTracker prefix-extend on 1 MB conversation → <1 s +- 10 × 1 MB concurrent sessions in LRU → no quadratic blowup + +Realistic upper bound: ~5 MB. Beyond that the upstream itself rejects +the request (over its context-window) before the router sees it. diff --git a/wiki/student-setup.md b/wiki/student-setup.md new file mode 100644 index 0000000..c2a06c9 --- /dev/null +++ b/wiki/student-setup.md @@ -0,0 +1,321 @@ +# Student setup — step by step + +This walks you from nothing to a working router capturing a **demo +assignment** end to end: + +1. what your TA gives you +2. get the `aimdware-router` binary +3. install + configure **Tbox** (your jBox WebDAV gateway) +4. pick your upstream LLM +5. write `aimdware.yaml` +6. start the router and verify it's healthy +7. point your coding agent at it +8. run the demo assignment and confirm a capture landed +9. troubleshooting + +> The router is a **visibility tool**, not enforcement. It runs on your +> own machine with your own credentials. See +> [threat-model.md](threat-model.md). + +--- + +## 0. What your TA gives you + +Before you start, get these four values from your TA (out of band — Feishu/email): + +| Value | Example | Used as | +|---|---|---| +| Student token | `st_9aBx…` | `student_token` — treat as a password | +| Backend URL | `https://aimdware.example.edu` | `backend_url` | +| Course code | `DEMO101` | `course` | +| Assignment slug | `demo1` | `assignment` (chars: `A–Z a–z 0–9 _ . -`) | + +The token is minted per student (`aimdware-admin token issue`). If it +ever leaks, ask your TA to rotate it. + +--- + +## 1. Get the router binary + +**Option A — download a prebuilt binary** (no toolchain needed). Grab the +one for your platform and rename it to `aimdware-router`: + +``` +aimdware-router-macos-arm64 # Apple Silicon +aimdware-router-macos-x64 # Intel Mac +aimdware-router-linux-arm64 +aimdware-router-linux-x64 +aimdware-router-windows-x64.exe +``` + +```bash +chmod +x aimdware-router # macOS/Linux +./aimdware-router --help +``` + +> macOS Gatekeeper may block an unsigned binary. If so: +> `xattr -d com.apple.quarantine ./aimdware-router`. + +**Option B — build from source** (needs [Bun](https://bun.sh) ≥ 1.3): + +```bash +cd llm-client +bun install +bun run build # → dist/aimdware-router (current platform) +# or all platforms at once: +bun run build:all # → dist/aimdware-router- +./dist/aimdware-router --help +``` + +Pick a working directory and keep the binary + your `aimdware.yaml` +together there. + +--- + +## 2. Install and configure Tbox (your WebDAV target) + +The router never stores your conversations itself — it **PUTs** each +captured blob to a WebDAV endpoint **you** control. The reference setup +is **jBox via Tbox**: Tbox runs a small local WebDAV server backed by +your jBox cloud storage. + +> **Not at SJTU / no jBox?** Any WebDAV server works (NextCloud, a +> self-hosted `webdav-server`, minio + a WebDAV frontend …). Skip to the +> three values you need at the end of this section and plug in your own +> endpoint. + +### 2.1 Download and sign in + +1. Open the jBox portal: **https://jbox.sjtu.edu.cn**. +2. Download the **Tbox** desktop client for your OS and install it. +3. Launch it and sign in with **jAccount**. + +### 2.2 Turn on the local WebDAV endpoint + +In Tbox's settings, find the **WebDAV / local mount** section and note +three things (labels vary by Tbox version): + +| You need | Goes into | Typical value | +|---|---|---| +| Local WebDAV URL | `tbox_url` | `http://127.0.0.1:50471` | +| WebDAV username | `tbox_user` | your jAccount, e.g. `alice` | +| WebDAV password / app token | `tbox_pass` | the token Tbox shows | + +> The port differs per machine/version — use whatever Tbox displays, not +> the example above. + +### 2.3 Verify WebDAV is reachable + +With Tbox running, this should return `200`/`207` (not "connection +refused"). Replace the URL/creds with yours: + +```bash +curl -u alice: -X PROPFIND http://127.0.0.1:50471/ -I +``` + +You do **not** need to pre-create any folders — the router creates +`aimdware///` automatically (MKCOL) on first upload. + +--- + +## 3. Pick your upstream LLM + +Choose **one** of these. It decides the `upstream:` block in step 5. + +### 3a. An OpenAI-compatible API (key-based) + +Anything that speaks the OpenAI API: the SJTU models gateway, OpenAI, +OpenRouter, DeepSeek, Kimi, GLM, Qwen, … You supply a `base_url` and +`api_key`. + +```yaml +upstream: + plugin: openai + base_url: https://models.sjtu.edu.cn/api/v1 + api_key: sk-... +``` + +### 3b. ChatGPT / Codex subscription (no API key) + +Uses your ChatGPT login instead of a key. Log in once (see step 6.1) and +set: + +```yaml +upstream: + plugin: codex +``` + +Codex is a **Responses-only** provider — point clients at +`/v1/responses` (not `/v1/chat/completions`). Your coding agent (Codex CLI / +opencode) formats requests correctly; the ChatGPT-account Codex backend only +accepts models your account exposes (e.g. `gpt-5.5` — *not* `gpt-5-codex`), +and needs `instructions` + `store:false` + a list `input`. + +> **Behind a proxy (e.g. in CN)?** `auth login codex` and every Codex request +> reach `auth.openai.com` / `chatgpt.com`. Export `HTTPS_PROXY` (only HTTPS, so +> the plain-HTTP backend stays direct) when you run the login and the router: +> `HTTPS_PROXY=http://127.0.0.1: ./aimdware-router --config ./aimdware.yaml` + +### 3c. GitHub Copilot subscription + +```yaml +upstream: + plugin: copilot +``` + +For 3b/3c the tokens live in `local_cache_dir/auth/auth.json`, **not** in +`aimdware.yaml`. + +--- + +## 4. Write `aimdware.yaml` + +Create `aimdware.yaml` next to the binary. Full annotated example for the +demo assignment using an OpenAI-compatible upstream: + +```yaml +# --- identity (from your TA) --- +student_token: st_REPLACE_ME +course: DEMO101 +assignment: demo1 +backend_url: https://aimdware.example.edu + +# --- upstream LLM (pick ONE block from step 3) --- +upstream: + plugin: openai + base_url: https://models.sjtu.edu.cn/api/v1 + api_key: sk-REPLACE_ME + +# --- WebDAV target (from Tbox, step 2) --- +tbox_url: http://127.0.0.1:50471 +tbox_user: REPLACE_ME +tbox_pass: REPLACE_ME + +# --- optional (defaults shown) --- +# port: 12345 # where the router listens +# local_cache_dir: ~/.cache/aimdware +# jbox_remote_path: aimdware/DEMO101/demo1 # must equal aimdware// +``` + +Lock the file down — it holds secrets: + +```bash +chmod 600 aimdware.yaml +``` + +--- + +## 5. (Codex/Copilot only) log in + +Skip if you chose 3a. Otherwise run the one-time device login: + +```bash +./aimdware-router --config ./aimdware.yaml auth login codex +# → opens https://auth.openai.com/codex/device, enter the printed code +./aimdware-router --config ./aimdware.yaml auth login copilot # for copilot + +./aimdware-router --config ./aimdware.yaml auth status +# codex: logged in token=… (redacted) +``` + +The access token auto-refreshes; you won't normally log in again. + +--- + +## 6. Start the router and verify + +```bash +./aimdware-router --config ./aimdware.yaml +``` + +On startup it prints a config summary (port, upstream, cache dir). In a +second terminal, confirm it's up: + +```bash +curl -s http://127.0.0.1:12345/healthz # → 200 +``` + +Quick forward test (OpenAI-compatible upstreams): + +```bash +curl -s http://127.0.0.1:12345/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'authorization: Bearer anything' \ + -d '{"model":"", + "messages":[{"role":"user","content":"say hi"}]}' +``` + +You should get a normal completion back. The router listens on loopback +only and accepts **any** non-empty `api_key` from your agent — the real +upstream credential is the one in `aimdware.yaml`. + +--- + +## 7. Point your coding agent at the router + +Set your agent's base URL to the router and use any dummy key. Examples: + +**OpenAI SDK / generic env:** + +```bash +export OPENAI_BASE_URL=http://127.0.0.1:12345/v1 +export OPENAI_API_KEY=dummy +``` + +**Codex / Responses clients** (when `plugin: codex`): point them at +`http://127.0.0.1:12345/v1/responses`. + +Whatever tool you use (Cline, Aider, OpenCode, Cursor, curl, …), the +rule is the same: **base URL → the router, key → anything**. + +--- + +## 8. Run the demo assignment and confirm capture + +1. Make sure **Tbox is running** and the router is up (steps 2, 6). +2. Do one real model call through your agent (or the curl in step 6). +3. Watch the router log — you'll see a line like: + + ``` + captured record=… session=… turn=1 hash=…… size=… -> queued + ``` + +4. Confirm the local blob exists: + + ```bash + ls ~/.cache/aimdware/records/ # one .json + ``` + +5. Confirm it reached jBox — the file appears under: + + ``` + aimdware/DEMO101/demo1/.json + ``` + + (visible in jBox/Tbox; the router PUTs it via WebDAV). + +6. Your TA can now see the metadata + fetch the blob for `DEMO101/demo1`. + +A multi-turn conversation collapses into **one** session file that is +overwritten each turn — that's expected (see +[design-notes.md](design-notes.md)). + +--- + +## 9. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `failed to read config at …` | wrong `--config` path | use the correct path / `cd` to the dir | +| Config validation error on `api_key` | `plugin: openai` needs `api_key` | add it, or switch to `codex`/`copilot` | +| `jbox_remote_path must be aimdware//` | overrode it with a non-canonical value | delete the override or match exactly | +| `Codex subscription is not logged in` | no/expired login | `auth login codex` | +| `Codex … refresh rejected … run auth login codex` | refresh token revoked | `auth login codex` again | +| Captures stay queued, never upload | Tbox down / wrong `tbox_*` | start Tbox; re-check URL/user/pass (step 2.3) | +| `does not support /v1/chat/completions` | `plugin: codex` got a Chat request | call `/v1/responses` instead | +| Agent calls fail with 4xx | wrong upstream `base_url`/model | verify with the step 6 curl | + +Cache files in `local_cache_dir` are safe to delete; they're rebuilt +from upstream/Tbox as needed. The SQLite outbox retries across restarts, +so a backend or Tbox outage loses no data — captures upload once the +endpoint is back. diff --git a/wiki/ta-deployment.md b/wiki/ta-deployment.md new file mode 100644 index 0000000..ead963f --- /dev/null +++ b/wiki/ta-deployment.md @@ -0,0 +1,307 @@ +# TA deployment — step by step + +For the teaching team. Takes you from nothing to: a running **backend** +(ingest API + admin audit), a **course + student onboarded**, and +**inspecting a captured blob** — paired with the demo assignment in +[student-setup.md](student-setup.md) (`DEMO101 / demo1`). + +What you operate: + +``` +student routers ── POST /ingest/* (token auth) ─► backend (FastAPI + Postgres) +TT tooling ── aimdware-admin (direct DB) ─┘ metadata + sha256 + blob URI only +TT tooling ── /admin/* (shared-secret) ── WebDAV ─► student jbox (audit reads) +``` + +The backend **never stores conversation bytes** — only metadata, a +sha256, and the jbox URI. Blobs are fetched live from WebDAV when you +audit. See [backend.md](backend.md) and [threat-model.md](threat-model.md). + +--- + +## 0. Secrets you will manage + +| Secret | Env var | Purpose | +|---|---|---| +| Postgres URL | `AIMDWARE_DATABASE_URL` | backend + admin CLI DB access | +| Admin shared secret | `AIMDWARE_ADMIN_SECRET` | bearer for all `/admin/*` endpoints | +| TT WebDAV creds | `AIMDWARE_TBOX_URL` / `_USER` / `_PASS` | fetch blobs from jbox for audit | +| Per-student tokens | (in DB as `sha256`) | minted via `aimdware-admin token issue` | + +All backend config is env-driven with the `AIMDWARE_` prefix +(`backend/src/aimdware_backend/settings.py`). + +--- + +## 1. Prerequisites + +- **Python ≥ 3.12** and **[uv](https://docs.astral.sh/uv/)**. +- **PostgreSQL** (prod). SQLite works for a quick local trial but use + Postgres for anything real. +- A **TT-side Tbox** (jBox WebDAV gateway) bound to a jAccount that has + **read access** to students' `aimdware/` folders — needed only for the + blob-inspection step (8). The read access is a jBox sharing + arrangement you set up with students / your institution; this repo + doesn't manage it. +- A host reachable by students over **HTTPS** for `backend_url`. + +--- + +## 2. Get the code and install + +```bash +cd backend +uv sync # creates .venv from uv.lock (incl. the aimdware-admin CLI) +``` + +`uv run ` runs inside that environment. All commands below are run +from the `backend/` directory. + +--- + +## 3. Provision PostgreSQL + +Create a database + user, then point the app at it: + +```sql +CREATE USER aimdware WITH PASSWORD '...'; +CREATE DATABASE aimdware OWNER aimdware; +``` + +**Install a Postgres driver** — the package pins none, so a bare +`postgresql://…` URL fails to connect. Add psycopg3 and use the matching URL: + +```bash +uv add "psycopg[binary]" +export AIMDWARE_DATABASE_URL='postgresql+psycopg://aimdware:...@localhost:5432/aimdware' +``` + +> (SQLite — the default `sqlite:///./aimdware.db` — needs no extra driver, for +> a quick local trial only.) + +--- + +## 4. Apply migrations + +Production schema is owned by **Alembic** (not `create_all`): + +```bash +uv run alembic upgrade head +``` + +This creates `users`, `courses`, `enrollments`, `student_tokens`, +`context_records` and the partial/unique indexes. Re-run after pulling +new migrations. + +--- + +## 5. Configure the rest of the environment + +Config is read from process env vars **and** from a `.env` file in the +directory you run from (real env vars win over `.env`). Pick whichever +fits your deploy. + +**Option A — a `backend/.env` file** (handy for local/dev; it's +gitignored): + +```dotenv +# backend/.env — all keys use the AIMDWARE_ prefix +AIMDWARE_DATABASE_URL=postgresql://aimdware:...@localhost:5432/aimdware +AIMDWARE_ADMIN_SECRET=<48+ random chars> +AIMDWARE_TBOX_URL=http://127.0.0.1:8089 +AIMDWARE_TBOX_USER=tt-jaccount +AIMDWARE_TBOX_PASS=... +``` + +`uv run uvicorn …`, `uv run alembic …` and `uv run aimdware-admin …` all +pick it up automatically when run from `backend/`. Generate the secret +with `python -c 'import secrets; print(secrets.token_urlsafe(48))'`. + +> Already exported `AIMDWARE_DATABASE_URL` in steps 3–4? You can drop it +> into `.env` instead and stop exporting it. + +**Option B — export / a process manager** (recommended for a real +service): + +```bash +export AIMDWARE_ADMIN_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" +export AIMDWARE_TBOX_URL='http://127.0.0.1:8089' +export AIMDWARE_TBOX_USER='tt-jaccount' +export AIMDWARE_TBOX_PASS='...' +``` + +For a systemd service use `EnvironmentFile=/etc/aimdware/backend.env` +(outside the repo), or your secret manager — not shell history. You can +also let uv load any file explicitly: `uv run --env-file /path/to.env …`. + +> If `AIMDWARE_ADMIN_SECRET` is empty, every `/admin/*` endpoint returns +> **503** (admin surface disabled) — useful if you want ingest-only. + +--- + +## 6. Run the backend + +The app object is `aimdware_backend.main:app`. + +```bash +# quick check (single worker, localhost) +uv run uvicorn aimdware_backend.main:app --host 127.0.0.1 --port 8000 + +# production-ish +uv run uvicorn aimdware_backend.main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +**Put it behind TLS.** Students send their `student_token` in an +`Authorization` header, so terminate HTTPS at a reverse proxy +(nginx/Caddy) in front of uvicorn and give students the `https://…` URL +as their `backend_url`. Keep the app bound to localhost / private network +behind the proxy. + +Sketch systemd unit: + +```ini +[Service] +WorkingDirectory=/opt/aimdware/backend +EnvironmentFile=/etc/aimdware/backend.env +ExecStart=/usr/bin/uv run uvicorn aimdware_backend.main:app --host 127.0.0.1 --port 8000 --workers 4 +Restart=on-failure +``` + +--- + +## 7. Verify it's up + +```bash +# unauthenticated liveness +curl -s https://aimdware.example.edu/ingest/health # → 200 + +# admin auth wired? (401 with a bad secret = enabled; 503 = secret unset) +curl -s -o /dev/null -w '%{http_code}\n' \ + -H 'Authorization: Bearer wrong' \ + https://aimdware.example.edu/admin/session/00000000-0000-0000-0000-000000000000/payload +``` + +--- + +## 8. Onboard the demo course + a student + +`aimdware-admin` talks **directly to the DB**, so it needs +`AIMDWARE_DATABASE_URL` (and, for `record payload`, the `TBOX_*` vars). +v1 has **no in-CLI access control** — DB/shell access *is* the authority. + +```bash +# course (matches the student guide's DEMO101 / demo1) +uv run aimdware-admin course create --code DEMO101 --title "Demo Course" --semester 2026-spring + +# student +uv run aimdware-admin user create --jaccount alice --email alice@sjtu.edu.cn --name "Alice Liu" +uv run aimdware-admin enroll --user alice --course DEMO101 --role student + +# mint the token — THIS is the only time the plaintext is shown +uv run aimdware-admin token issue --user alice +# → {"prefix":"st_K9aB6r","plaintext":"st_..."} capture it, hand it to Alice +``` + +Give Alice four values for her `aimdware.yaml`: the **plaintext token**, +your `backend_url`, `course=DEMO101`, `assignment=demo1`. + +**Whole class at once?** Put the roster in a `名字,学号,jaccount` CSV and use +the `--csv` batch forms (email is derived `@sjtu.edu.cn`, 学号 stored +as `student_id`): + +```bash +uv run aimdware-admin user create --csv roster.csv +uv run aimdware-admin enroll --csv roster.csv --course DEMO101 +uv run aimdware-admin token issue --csv roster.csv > tokens.json # jaccount→plaintext, distribute +``` + +See [admin-script.md](admin-script.md) for the batch output format. + +> **Assignments need no setup.** `assignment` is a free-form course-scoped +> string checked by equality at ingest — `demo1` "exists" the moment a +> record arrives with it. Just agree on the slug +> (`A–Z a–z 0–9 _ . -`) with students. + +> `--role admin` enrollment is reserved for future course-scoped +> authority; in v1 admin power comes from the shared secret + DB access, +> not from an `admin` enrollment. + +--- + +## 9. Inspect captures (audit) + +After Alice runs the demo (one model call through her router), records +appear. List them: + +```bash +uv run aimdware-admin record list --course DEMO101 --assignment demo1 +# add: --user alice --status uploaded --limit 20 +``` + +Fetch + verify a blob (pulls from your Tbox, recomputes sha256): + +```bash +# needs AIMDWARE_TBOX_URL/_USER/_PASS exported (step 5) +uv run aimdware-admin record payload --id \ + | jq -r '.payload_utf8' | jq '.request.messages' +``` + +Or via the HTTP admin API (same logic, shared-secret auth). Prefer the +**session** endpoint for canonical verification — the on-jbox blob is +always the latest turn: + +```bash +curl -s -H "Authorization: Bearer $AIMDWARE_ADMIN_SECRET" \ + https://aimdware.example.edu/admin/session//payload | jq .verified +``` + +**Multi-turn caveat:** a session's jbox file is **overwritten each +turn**, so only the latest turn's `blob_hash` matches what's on jbox. On +`/admin/context//payload`, the `is_latest_turn` flag tells you +whether a mismatch is tampering vs. just an older turn. Use the session +endpoint to verify "what's there now". `blob_status` values: +`pending → uploaded → verified | tampered | missing` (see +[backend.md](backend.md)). + +--- + +## 10. Token rotation & revocation + +```bash +uv run aimdware-admin token list --user alice +uv run aimdware-admin token issue --user alice # rotate: revokes old, prints new +uv run aimdware-admin token revoke --prefix st_K9aB6r # kill a leaked token +``` + +At most one active token per student. If a student loses the plaintext, +`token issue` again is the only path (the backend stores only the hash). + +--- + +## 11. Security & ops recap + +- **No blobs on the backend.** A full DB compromise yields metadata + + hashes + URIs, never student work — that lives on jbox accounts the + backend can't decrypt. +- **DB stores only `sha256(token)`.** A leak exposes no usable token; + respond by rotating (step 10). +- **`/admin/*` = shared secret.** Treat `AIMDWARE_ADMIN_SECRET` like a + root password; rotate it by changing the env var and restarting. +- **A stolen student token is write-only** and scoped to that student's + enrolled courses; it cannot read anyone's data. +- **Backups:** back up Postgres for the audit trail. Blobs are the + students' (on jbox) — not your responsibility to retain. + +--- + +## 12. Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `/admin/*` returns 503 | `AIMDWARE_ADMIN_SECRET` unset | export it, restart | +| Ingest returns 403 | student not enrolled in `course_code` | `aimdware-admin enroll …` | +| Ingest returns 409 | replayed `record_id` w/ different body, or `(session_id,turn_count)` clash | usually benign (router retry); investigate if persistent | +| `record payload` can't fetch | `TBOX_*` unset / Tbox down / no read access to that folder | export creds, start Tbox, fix jbox sharing | +| `verified: false` with `is_latest_turn: false` | you verified an older turn | use `/admin/session//payload` | +| alembic can't connect | bad `AIMDWARE_DATABASE_URL` / driver | fix URL, install the driver | +| students' tokens all rejected | wrong `backend_url` or DB mismatch | confirm proxy → app, same DB the CLI uses | diff --git a/wiki/threat-model.md b/wiki/threat-model.md index b006cae..e75be22 100644 --- a/wiki/threat-model.md +++ b/wiki/threat-model.md @@ -21,8 +21,17 @@ Under these, no client-side software can guarantee monitoring. - **Accidental violations** — students unaware AI use needed disclosure. - **Server-side data leaks** — backend stores no content, only metadata + hash + URI. A full DB compromise yields no student work. - Write-only ingest bounds a stolen course token to writes for one - (student, course). + Write-only ingest bounds a stolen student token to fake writes for + that student's enrolled courses; it can't read anything. +- **Backend DB leak doesn't leak tokens** — only `sha256(token)` is + stored. Tokens are 256-bit random; rainbow tables don't apply. A + full DB dump can't be used to impersonate students against a live + backend. +- **Token rotation as the response to client-side leakage.** Plaintext + lives in the router's `config.json` (mode 600). If that file is + compromised, the only mitigation is `aimdware-admin token rotate`; + the leaked token cannot be retroactively scrubbed from the student's + machine. ## What it does NOT protect against @@ -34,6 +43,11 @@ Under these, no client-side software can guarantee monitoring. Requires modifying the source. - **Self-reported metadata.** Router version, agent client id, model string — a modified router can lie. Treat as advisory. +- **Client-internal tools the router never sees.** If a client (e.g. + opencode) does a local Read tool call that mutates context **before** + hitting the LLM, the router only sees the result-as-text, not the + tool exchange. See [design-notes.md → "What the router does NOT + see"](design-notes.md). - **Subscription TOS.** If subscription support ever lands, students are responsible for compliance with the LLM provider's terms (Anthropic's Feb 2026 policy bans third-party OAuth use).