From 6fbadb7107c69311088f603d5d71a38265012a30 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 20:31:48 +0800 Subject: [PATCH 01/60] docs(wiki): update wiki --- README.md | 2 +- wiki/admin-script.md | 9 +++--- wiki/architecture.md | 65 ++++++++++++++++++++++++-------------------- wiki/backend.md | 54 +++++++++++++++++++----------------- wiki/llm-client.md | 10 +++++-- wiki/threat-model.md | 4 +-- 6 files changed, 80 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 890bee3..49b33ca 100644 --- a/README.md +++ b/README.md @@ -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/wiki/admin-script.md b/wiki/admin-script.md index d0bac27..68a8934 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -28,15 +28,16 @@ aimdware-admin user create --jaccount zhangsan --email z@sjtu.edu.cn --display 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 token issue --user zhangsan # one token per student; not course-scoped +aimdware-admin token revoke --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] ``` -- `token issue` prints the plaintext course token once; hand it directly - to the student. +- `token issue` prints the plaintext student token once; hand it + directly to the student. The token is not course-scoped; the student's + router config supplies the active course per request. - `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 diff --git a/wiki/architecture.md b/wiki/architecture.md index 302c1f5..ebcd886 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -6,35 +6,36 @@ 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 +course via `enrollments` -- being an admin in CS101 grants no access to CS201's data. ``` - ┌───────────────────────┐ - │ 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 (admin) | ---- aimdware-admin CLI + +-----------------------+ (direct Postgres) + + jbox via own jaccount + +-----------------------+ + | Student | ---- upload via router + +-----------------------+ + | + v + +--------------------------+ +-----------------+ + | Backend (ingest only) |<---| Client router | + | metadata + hash only | | student's | + +--------------------------+ | localhost | + +--+--------+-----+ + | | + blob | | chat + (JSON)| | completion + v v + +----------------------+ +--------------+ + | Student's jbox | | Upstream LLM | + | (1 TB/student quota) | | (OpenAI etc.)| + +----------------------+ +--------------+ ``` The router is the only piece that sees the student's LLM credential. -The backend stores no payload content — only metadata, hash, and the +The backend stores no payload content -- only metadata, hash, and the jbox URI. ## Components @@ -57,18 +58,22 @@ fetches blobs from jbox for inspection. | Credential | Held by | Used for | | ------------------- | ------- | ----------------------------- | -| Course token | student | router -> backend ingest auth | +| Student token | student | router -> backend ingest auth | | Student LLM API key | student | router -> upstream LLM | -The router holds no jbox secret — auth lives in the student's locally +One student token per student; the course context is sent in each +ingest request body (`course_code`). 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 they +are enrolled in; LLM access is unaffected. + +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. +The backend holds no jbox credential in v1. ## Roles and RBAC -`enrollments(user_id, course_id, role)` with `role ∈ {student, admin}`. +`enrollments(user_id, course_id, role)` with `role` in `{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 @@ -77,7 +82,7 @@ bypasses it. ## Storage split - Postgres: metadata + hash + URI only. ~500 B per row; for 20 courses - × 100 active students × 50 req/day × 100 days ≈ 5 GB / semester. + x 100 active students x 50 req/day x 100 days ~ 5 GB / semester. - jbox (per student): full JSON payloads, addressed by record id. - Tamper detection by hash. diff --git a/wiki/backend.md b/wiki/backend.md index cb0bdaa..f4e4895 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -1,19 +1,19 @@ # Backend - Python 3.12 + FastAPI + SQLModel + Alembic + PostgreSQL -- Auth: course-token bearer for `/ingest/*`. No sessions, no password +- Auth: student-token bearer for `/ingest/*`. No sessions, no password hashing in v1. ## Ingest API -Caller: student router. Auth: course token in `Authorization: Bearer ct_...`. +Caller: student router. Auth: student token in `Authorization: Bearer st_...`. 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. | +| Method | Path | Function | +| ------ | -------------------------------------- | -------- | +| `GET` | `/ingest/health` | Unauthenticated liveness probe. | +| `POST` | `/ingest/context` | Record one entry. Body: `{ record_id, course_code, blob_hash, blob_uri, blob_size, model, prompt_tokens, completion_tokens, ts, router_version, client_meta }`. Backend resolves `student_id` from the token, resolves `course_id` from `course_code`, verifies `Enrollment(student, course, role=student)` exists, 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`. Returns `403` if the student is not enrolled in the named course. | +| `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. @@ -75,20 +75,20 @@ class Enrollment(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.utcnow) -class CourseToken(SQLModel, table=True): - __tablename__ = "course_tokens" +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) - 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 - # 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"),) + # Partial unique index on (user_id) WHERE revoked_at IS NULL + # is created in the Alembic migration to enforce one active token per + # student. Course context is supplied per-request in /ingest/context. + __table_args__ = (Index("ix_student_tokens_user", "user_id"),) class ContextRecord(SQLModel, table=True): @@ -113,12 +113,15 @@ class ContextRecord(SQLModel, table=True): 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. +- One `StudentToken` per student. Rotation inserts a new row and sets + `revoked_at` on the old; the partial unique index enforces "at most + one active token per student". +- Course context per ingest is supplied via `course_code` in the request + body. Backend resolves the course and verifies the student is enrolled + in it (`role = student`); otherwise rejects with 403. +- `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. @@ -127,12 +130,13 @@ Notes: ## 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. +- **Token → student attribution.** The token identifies the student; + the router cannot impersonate someone else by tampering with the body. +- **Course is verified per request.** Backend rejects ingest for any + course the student is not enrolled in. +- **Write-only ingest.** No HTTP read surface, so a stolen student + token writes at most fake records for that student's enrolled courses. - **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 +- **Constant-time compare** on student-token validation; tokens never in logs. diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 6fd481f..5be1dde 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -13,16 +13,22 @@ Both happen in parallel, both off the student's critical path. ## 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; sent with every ingest call 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 +jbox_remote_path: aimdware/ # target path inside jbox cloud backend_url: https://aimdware.sjtu.edu # hardcoded per build / overridable via flag ``` +`student_token` is one credential per student; `course` selects which +course context this router instance reports to. A student enrolled in +multiple courses runs one router instance per course (different `port` +and `course`), or a single instance with multi-course config (post-v1). + The router holds no jbox credential — auth lives inside Tbox. ## Output diff --git a/wiki/threat-model.md b/wiki/threat-model.md index b006cae..02954c3 100644 --- a/wiki/threat-model.md +++ b/wiki/threat-model.md @@ -21,8 +21,8 @@ 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. ## What it does NOT protect against From b660de03f41de90f8c4ee2acf1c7991f97adb9ef Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 21:08:57 +0800 Subject: [PATCH 02/60] feat(llm-client): scaffold project --- .gitignore | 7 ++ llm-client/.gitignore | 6 ++ llm-client/package.json | 21 ++++ llm-client/src/config.test.ts | 83 +++++++++++++++ llm-client/src/config.ts | 32 ++++++ llm-client/src/net.test.ts | 91 +++++++++++++++++ llm-client/src/net.ts | 62 +++++++++++ llm-client/src/proxy.test.ts | 187 ++++++++++++++++++++++++++++++++++ llm-client/src/proxy.ts | 69 +++++++++++++ llm-client/src/server.test.ts | 23 +++++ llm-client/src/server.ts | 37 +++++++ llm-client/tsconfig.json | 19 ++++ 12 files changed, 637 insertions(+) create mode 100644 .gitignore create mode 100644 llm-client/.gitignore create mode 100644 llm-client/package.json create mode 100644 llm-client/src/config.test.ts create mode 100644 llm-client/src/config.ts create mode 100644 llm-client/src/net.test.ts create mode 100644 llm-client/src/net.ts create mode 100644 llm-client/src/proxy.test.ts create mode 100644 llm-client/src/proxy.ts create mode 100644 llm-client/src/server.test.ts create mode 100644 llm-client/src/server.ts create mode 100644 llm-client/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d4b8a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +third_party/ +node_modules/ +dist/ +*.log +.DS_Store +bun.lock +bun.lockb diff --git a/llm-client/.gitignore b/llm-client/.gitignore new file mode 100644 index 0000000..12bee28 --- /dev/null +++ b/llm-client/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +bun.lock +bun.lockb diff --git a/llm-client/package.json b/llm-client/package.json new file mode 100644 index 0000000..671d381 --- /dev/null +++ b/llm-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "aimdware-llm-client", + "version": "0.0.0", + "private": true, + "type": "module", + "module": "src/main.ts", + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit", + "dev": "bun run src/main.ts", + "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/aimdware-router" + }, + "dependencies": { + "yaml": "^2.5.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "^1.1.10", + "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..9851cd0 --- /dev/null +++ b/llm-client/src/config.test.ts @@ -0,0 +1,83 @@ +import { test, expect } from "bun:test"; +import { loadConfig } from "./config"; + +test("loadConfig parses a minimal config", () => { + const yaml = ` +student_token: st_abc123 +course: ECE4721J +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 +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"); +}); + +test("loadConfig rejects missing required fields", () => { + const cases: Array<[string, string]> = [ + [ + "missing student_token", + `course: X\nupstream:\n api_key: k\nbackend_url: u`, + ], + [ + "missing course", + `student_token: t\nupstream:\n api_key: k\nbackend_url: u`, + ], + [ + "missing upstream.api_key", + `student_token: t\ncourse: X\nupstream: {}\nbackend_url: u`, + ], + [ + "missing backend_url", + `student_token: t\ncourse: X\nupstream:\n api_key: k`, + ], + ]; + for (const [label, yaml] of cases) { + expect(() => loadConfig(yaml), label).toThrow(); + } +}); + +test("loadConfig defaults upstream.type to 'openai'", () => { + const yaml = ` +student_token: st_x +course: ECE4721J +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 +upstream: + type: openai + api_key: sk-x +backend_url: https://b.example +`; + const config = loadConfig(yaml); + expect(config.upstream.type).toBe("openai"); +}); diff --git a/llm-client/src/config.ts b/llm-client/src/config.ts new file mode 100644 index 0000000..55af112 --- /dev/null +++ b/llm-client/src/config.ts @@ -0,0 +1,32 @@ +import YAML from "yaml"; +import { z } from "zod"; + +const UpstreamSchema = z.object({ + type: z.enum(["openai", "codex", "copilot"]).default("openai"), + base_url: z.string().default("https://api.openai.com"), + api_key: z.string().min(1, "upstream.api_key is required"), +}); + +const RawConfigSchema = z.object({ + student_token: z.string().min(1, "student_token is required"), + course: z.string().min(1, "course is required"), + 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"), +}); + +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); + return { + ...parsed, + jbox_remote_path: + parsed.jbox_remote_path ?? `aimdware/${parsed.course}`, + }; +} diff --git a/llm-client/src/net.test.ts b/llm-client/src/net.test.ts new file mode 100644 index 0000000..77a44cf --- /dev/null +++ b/llm-client/src/net.test.ts @@ -0,0 +1,91 @@ +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 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 * 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/net.ts b/llm-client/src/net.ts new file mode 100644 index 0000000..0934adc --- /dev/null +++ b/llm-client/src/net.ts @@ -0,0 +1,62 @@ +/** + * 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) => 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) { + return true; + } + } + return false; +} diff --git a/llm-client/src/proxy.test.ts b/llm-client/src/proxy.test.ts new file mode 100644 index 0000000..7a9d893 --- /dev/null +++ b/llm-client/src/proxy.test.ts @@ -0,0 +1,187 @@ +import { test, expect, afterEach, beforeEach } from "bun:test"; +import type { Server } from "bun"; +import { proxyChat, 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("")); +}); diff --git a/llm-client/src/proxy.ts b/llm-client/src/proxy.ts new file mode 100644 index 0000000..f0ba215 --- /dev/null +++ b/llm-client/src/proxy.ts @@ -0,0 +1,69 @@ +import { getProxyForUrl } from "./net"; + +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, + opts: ProxyChatOpts = {}, +): Promise { + const inboundUrl = new URL(inbound.url); + const target = new URL( + inboundUrl.pathname + inboundUrl.search, + upstream.base_url, + ); + + const forwardedHeaders = new Headers(); + inbound.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + forwardedHeaders.set(key, value); + } + }); + forwardedHeaders.set("authorization", `Bearer ${upstream.api_key}`); + + const proxy = getProxyForUrl(target); + const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike); + + const init: RequestInit & { proxy?: string } = { + method: inbound.method, + headers: forwardedHeaders, + body: + inbound.method === "GET" || inbound.method === "HEAD" + ? undefined + : await inbound.arrayBuffer(), + }; + if (proxy !== undefined) init.proxy = proxy; + + const upstreamRes = await f(target, init); + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: upstreamRes.headers, + }); +} diff --git a/llm-client/src/server.test.ts b/llm-client/src/server.test.ts new file mode 100644 index 0000000..663283d --- /dev/null +++ b/llm-client/src/server.test.ts @@ -0,0 +1,23 @@ +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("GET /healthz returns 200 ok", async () => { + handle = await startServer({ port: 0, hostname: "127.0.0.1" }); + + const res = await fetch(`http://127.0.0.1:${handle.port}/healthz`); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); +}); + +test("server binds to 127.0.0.1 only (not 0.0.0.0)", async () => { + handle = await startServer({ port: 0, hostname: "127.0.0.1" }); + expect(handle.hostname).toBe("127.0.0.1"); +}); diff --git a/llm-client/src/server.ts b/llm-client/src/server.ts new file mode 100644 index 0000000..1a6314c --- /dev/null +++ b/llm-client/src/server.ts @@ -0,0 +1,37 @@ +export type ServerOptions = { + port: number; + hostname: string; +}; + +export type ServerHandle = { + port: number; + hostname: string; + stop: () => Promise; +}; + +export async function startServer( + opts: ServerOptions, +): Promise { + const server = Bun.serve({ + port: opts.port, + hostname: opts.hostname, + fetch(req) { + const url = new URL(req.url); + if (req.method === "GET" && url.pathname === "/healthz") { + return new Response("ok", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + + 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/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/**/*"] +} From 465e4f1af945306d1fdb71db32d1f4d9151a9263 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 21:16:53 +0800 Subject: [PATCH 03/60] feat(llm-client): add capture pipeline --- .gitignore | 3 +- llm-client/.gitignore | 3 +- llm-client/bun.lock | 32 +++++++++ llm-client/src/capture.test.ts | 105 ++++++++++++++++++++++++++++++ llm-client/src/capture.ts | 114 +++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 llm-client/bun.lock create mode 100644 llm-client/src/capture.test.ts create mode 100644 llm-client/src/capture.ts diff --git a/.gitignore b/.gitignore index 3d4b8a7..4c5eda6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ node_modules/ dist/ *.log .DS_Store -bun.lock -bun.lockb + diff --git a/llm-client/.gitignore b/llm-client/.gitignore index 12bee28..d451c1b 100644 --- a/llm-client/.gitignore +++ b/llm-client/.gitignore @@ -2,5 +2,4 @@ node_modules/ dist/ *.log .DS_Store -bun.lock -bun.lockb + diff --git a/llm-client/bun.lock b/llm-client/bun.lock new file mode 100644 index 0000000..274c988 --- /dev/null +++ b/llm-client/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "aimdware-llm-client", + "dependencies": { + "yaml": "^2.5.1", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "^1.1.10", + "typescript": "^5.6.3", + }, + }, + }, + "packages": { + "@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=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "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=="], + + "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/src/capture.test.ts b/llm-client/src/capture.test.ts new file mode 100644 index 0000000..4ad753e --- /dev/null +++ b/llm-client/src/capture.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from "bun:test"; +import { captureChat } 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 response — clientResponse byte-exact + blob holds request + response", async () => { + const requestText = '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'; + const responseText = '{"id":"chatcmpl-xyz","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 result = await captureP; + const blob = JSON.parse(dec(result.blob_bytes)); + expect(blob.request_text).toBe(requestText); + expect(blob.response_text).toBe(responseText); + expect(blob.upstream_status).toBe(200); + expect(typeof blob.ts).toBe("string"); + expect(result.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 response — clientResponse byte-for-byte + blob has concatenated SSE text", async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n', + "data: [DONE]\n\n", + ]; + const requestText = '{"model":"gpt-4o","stream":true,"messages":[]}'; + + const upstreamRes = makeStreamingResponse(chunks); + const { clientResponse, captureP } = captureChat(enc(requestText), upstreamRes); + + expect(await clientResponse.text()).toBe(chunks.join("")); + + const result = await captureP; + const blob = JSON.parse(dec(result.blob_bytes)); + expect(blob.response_text).toBe(chunks.join("")); + expect(blob.request_text).toBe(requestText); +}); + +test("captureChat: blob_hash equals sha256(blob_bytes)", async () => { + const upstreamRes = new Response("response", { status: 200 }); + const { captureP } = captureChat(enc("request"), upstreamRes); + const result = await captureP; + + const expected = new Bun.CryptoHasher("sha256").update(result.blob_bytes).digest(); + expect(Buffer.from(result.blob_hash)).toEqual(Buffer.from(expected as Uint8Array)); + expect(result.blob_size).toBe(result.blob_bytes.byteLength); +}); + +test("captureChat: 204 / empty body — captures empty response_text without hanging", async () => { + const upstreamRes = new Response(null, { status: 204 }); + const { clientResponse, captureP } = captureChat(enc("x"), upstreamRes); + expect(clientResponse.status).toBe(204); + + const result = await captureP; + const blob = JSON.parse(dec(result.blob_bytes)); + expect(blob.response_text).toBe(""); + expect(blob.upstream_status).toBe(204); +}); + +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); + + // Read the first chunk then cancel + const reader = clientResponse.body!.getReader(); + await reader.read(); + await reader.cancel("client gone"); + + const result = await captureP; + const blob = JSON.parse(dec(result.blob_bytes)); + expect(blob.response_text).toBe(chunks.join("")); +}); diff --git a/llm-client/src/capture.ts b/llm-client/src/capture.ts new file mode 100644 index 0000000..946246a --- /dev/null +++ b/llm-client/src/capture.ts @@ -0,0 +1,114 @@ +export type CaptureResult = { + record_id: string; + ts: Date; + blob_bytes: Uint8Array; + blob_hash: Uint8Array; + blob_size: number; +}; + +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( + buildResult({ + record_id, + ts, + requestBytes, + responseChunks: [], + upstream_status, + }), + ); + 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 buildResult({ + record_id, + ts, + requestBytes, + responseChunks: chunks, + upstream_status, + }); + })(); + + return { clientResponse, captureP }; +} + +function buildResult(args: { + record_id: string; + ts: Date; + requestBytes: Uint8Array; + responseChunks: Uint8Array[]; + upstream_status: number; +}): CaptureResult { + const response_text = decodeBytes(concatChunks(args.responseChunks)); + const request_text = decodeBytes(args.requestBytes); + // Field order is deliberate: changing it changes the hash, so it's + // pinned via the literal here. JSON.stringify preserves insertion + // order for plain objects in V8/JSC/Bun. + const blob = { + record_id: args.record_id, + ts: args.ts.toISOString(), + upstream_status: args.upstream_status, + request_text, + response_text, + }; + const blob_bytes = new TextEncoder().encode(JSON.stringify(blob)); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(blob_bytes); + const blob_hash = hasher.digest() as Uint8Array; + return { + record_id: args.record_id, + ts: args.ts, + blob_bytes, + blob_hash, + blob_size: blob_bytes.byteLength, + }; +} + +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; +} From 1695d95615ac6322e0ea0412346aa53a2a19e5ea Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 21:21:30 +0800 Subject: [PATCH 04/60] feat(llm-client): wire end-to-end handler + main entry --- .gitignore | 1 + llm-client/src/handler.test.ts | 155 +++++++++++++++++++++++++++++++++ llm-client/src/handler.ts | 55 ++++++++++++ llm-client/src/main.test.ts | 93 ++++++++++++++++++++ llm-client/src/main.ts | 68 +++++++++++++++ llm-client/src/server.test.ts | 28 ++++-- llm-client/src/server.ts | 12 +-- 7 files changed, 397 insertions(+), 15 deletions(-) create mode 100644 llm-client/src/handler.test.ts create mode 100644 llm-client/src/handler.ts create mode 100644 llm-client/src/main.test.ts create mode 100644 llm-client/src/main.ts diff --git a/.gitignore b/.gitignore index 4c5eda6..021882c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.log .DS_Store +.*.bun-build diff --git a/llm-client/src/handler.test.ts b/llm-client/src/handler.test.ts new file mode 100644 index 0000000..757946c --- /dev/null +++ b/llm-client/src/handler.test.ts @@ -0,0 +1,155 @@ +import { test, expect, afterEach, beforeEach } from "bun:test"; +import type { Server } from "bun"; +import { createHandler } from "./handler"; +import type { CaptureResult } from "./capture"; + +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}` }; +} + +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/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(); + const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); + expect(blob.request_text).toBe('{"model":"gpt-4o","messages":[]}'); + expect(blob.response_text).toBe('{"id":"y","model":"gpt-4o"}'); + expect(blob.upstream_status).toBe(200); +}); + +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(); + const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); + expect(blob.response_text).toBe(chunks.join("")); +}); diff --git a/llm-client/src/handler.ts b/llm-client/src/handler.ts new file mode 100644 index 0000000..5b02f14 --- /dev/null +++ b/llm-client/src/handler.ts @@ -0,0 +1,55 @@ +import { captureChat, type CaptureResult } from "./capture"; +import { proxyChat, type FetchLike, type UpstreamConfig } from "./proxy"; + +export type HandlerOpts = { + upstream: UpstreamConfig; + onCapture?: (result: CaptureResult) => void; + 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 handleChat(req, opts); + } + + return new Response("not found", { status: 404 }); + }; +} + +async function handleChat(req: Request, opts: HandlerOpts): Promise { + const requestBytes = new Uint8Array(await req.arrayBuffer()); + + // proxyChat 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 proxyChat(proxyReq, opts.upstream, { + fetchImpl: opts.fetchImpl, + }); + + const { clientResponse, captureP } = captureChat(requestBytes, upstreamRes); + + captureP.then( + (result) => { + opts.onCapture?.(result); + }, + (err) => { + console.error("capture failed:", err); + }, + ); + + return clientResponse; +} diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts new file mode 100644 index 0000000..291a57e --- /dev/null +++ b/llm-client/src/main.test.ts @@ -0,0 +1,93 @@ +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 +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: process.cwd(), + 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); + } +}); diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts new file mode 100644 index 0000000..992cbdc --- /dev/null +++ b/llm-client/src/main.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env bun +import { parseArgs } from "node:util"; +import { readFile } from "node:fs/promises"; +import { loadConfig } from "./config"; +import { createHandler } from "./handler"; +import { startServer } from "./server"; + +async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + config: { type: "string", short: "c", default: "./aimdware.yaml" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + }); + + if (values.help) { + console.log("Usage: aimdware-router [--config ]"); + 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 handler = createHandler({ + upstream: { base_url: config.upstream.base_url, api_key: config.upstream.api_key }, + onCapture: (result) => { + // Phase 1 stub: replaced by ingest + jbox sync in later phases. + const hex = Buffer.from(result.blob_hash).toString("hex").slice(0, 16); + console.log( + `captured record=${result.record_id} hash=${hex}… size=${result.blob_size}`, + ); + }, + }); + + const handle = await startServer( + { port: config.port, hostname: "127.0.0.1" }, + handler, + ); + + console.log( + `aimdware-router listening on http://${handle.hostname}:${handle.port}`, + ); + console.log(` upstream: ${config.upstream.base_url} (${config.upstream.type})`); + console.log(` course: ${config.course}`); + console.log(` backend: ${config.backend_url}`); + + const shutdown = async (signal: string) => { + console.log(`\n${signal} received, stopping`); + await handle.stop(); + 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/server.test.ts b/llm-client/src/server.test.ts index 663283d..d632b27 100644 --- a/llm-client/src/server.test.ts +++ b/llm-client/src/server.test.ts @@ -8,16 +8,30 @@ afterEach(async () => { handle = undefined; }); -test("GET /healthz returns 200 ok", async () => { - handle = await startServer({ port: 0, hostname: "127.0.0.1" }); - - const res = await fetch(`http://127.0.0.1:${handle.port}/healthz`); +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("ok"); + expect(await res.text()).toBe("echo: /anything"); }); -test("server binds to 127.0.0.1 only (not 0.0.0.0)", async () => { - handle = await startServer({ port: 0, hostname: "127.0.0.1" }); +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/server.ts b/llm-client/src/server.ts index 1a6314c..2c51d48 100644 --- a/llm-client/src/server.ts +++ b/llm-client/src/server.ts @@ -9,21 +9,17 @@ export type ServerHandle = { 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(req) { - const url = new URL(req.url); - if (req.method === "GET" && url.pathname === "/healthz") { - return new Response("ok", { status: 200 }); - } - return new Response("not found", { status: 404 }); - }, + fetch: handler, }); - if (server.port === undefined) { throw new Error("server.port unexpectedly undefined (unix socket?)"); } From 7c9c33c420c9a060ceef0b5f549ddebd930db635 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 21:34:40 +0800 Subject: [PATCH 05/60] feat(llm-client): backend ingest client with persistent retry queue --- llm-client/src/ingest-worker.test.ts | 188 +++++++++++++++++++++++++++ llm-client/src/ingest-worker.ts | 131 +++++++++++++++++++ llm-client/src/ingest.test.ts | 115 ++++++++++++++++ llm-client/src/ingest.ts | 75 +++++++++++ llm-client/src/main.ts | 63 ++++++++- llm-client/src/queue.test.ts | 113 ++++++++++++++++ llm-client/src/queue.ts | 112 ++++++++++++++++ 7 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 llm-client/src/ingest-worker.test.ts create mode 100644 llm-client/src/ingest-worker.ts create mode 100644 llm-client/src/ingest.test.ts create mode 100644 llm-client/src/ingest.ts create mode 100644 llm-client/src/queue.test.ts create mode 100644 llm-client/src/queue.ts diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/ingest-worker.test.ts new file mode 100644 index 0000000..8010a81 --- /dev/null +++ b/llm-client/src/ingest-worker.test.ts @@ -0,0 +1,188 @@ +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, PostContextResult } from "./ingest"; +import { + nextBackoff, + DEFAULT_BACKOFF, + runOnce, + type PostContextImpl, +} from "./ingest-worker"; + +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, + course_code: "ECE4721J", + 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("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(3)).toBe(5 * 60_000); + expect(nextBackoff(4)).toBe(30 * 60_000); + expect(nextBackoff(5)).toBe(60 * 60_000); + expect(nextBackoff(99)).toBe(60 * 60_000); + expect(DEFAULT_BACKOFF.length).toBe(6); +}); + +test("runOnce: 202 -> markSent", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fake: PostContextImpl = async () => ({ kind: "created", record_id: "r1" }); + const summary = await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(summary.created).toBe(1); + expect(q.statusOf("r1")?.state).toBe("sent"); + q.close(); +}); + +test("runOnce: 200 -> exists also moves to sent", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fake: PostContextImpl = async () => ({ kind: "exists", record_id: "r1" }); + const summary = await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(summary.exists).toBe(1); + expect(q.statusOf("r1")?.state).toBe("sent"); + q.close(); +}); + +test("runOnce: retryable -> markRetry with exponential backoff", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fake: PostContextImpl = async () => ({ + kind: "retryable", + status: 503, + reason: "down", + }); + + await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + + let s = q.statusOf("r1")!; + expect(s.state).toBe("pending"); + expect(s.attempts).toBe(1); + expect(s.next_attempt_at).toBe(1000 + 1_000); + + // simulate time passing, retry again, attempts grows + backoff grows + await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 5000, + }); + s = q.statusOf("r1")!; + expect(s.attempts).toBe(2); + expect(s.next_attempt_at).toBe(5000 + 5_000); + + q.close(); +}); + +test("runOnce: conflict -> markTerminal(conflict), no retry", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fake: PostContextImpl = async () => ({ kind: "conflict" }); + await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(q.statusOf("r1")?.state).toBe("conflict"); + expect(q.pickReady(Number.MAX_SAFE_INTEGER, 10)).toHaveLength(0); + q.close(); +}); + +test("runOnce: fatal -> markTerminal(fatal), no retry", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 0); + const fake: PostContextImpl = async () => ({ + kind: "fatal", + status: 401, + reason: "auth", + }); + await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(q.statusOf("r1")?.state).toBe("fatal"); + expect(q.statusOf("r1")?.last_error).toContain("auth"); + q.close(); +}); + +test("runOnce: nothing ready -> processed = 0", async () => { + const q = freshQueue(); + q.enqueue(body("r1"), 5000); // scheduled in the future + const fake: PostContextImpl = async () => ({ kind: "created", record_id: "r1" }); + const summary = await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(summary.processed).toBe(0); + q.close(); +}); + +test("runOnce: multiple records in one pass", async () => { + const q = freshQueue(); + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.enqueue(body("c"), 5000); // not ready + const fake: PostContextImpl = async (_b, _t, body) => ({ + kind: "created", + record_id: body.record_id, + }); + const summary = await runOnce({ + queue: q, + backendUrl: "http://stub", + studentToken: "st", + postContextImpl: fake, + now: () => 1000, + }); + expect(summary.processed).toBe(2); + expect(summary.created).toBe(2); + expect(q.statusOf("a")?.state).toBe("sent"); + expect(q.statusOf("b")?.state).toBe("sent"); + expect(q.statusOf("c")?.state).toBe("pending"); + q.close(); +}); diff --git a/llm-client/src/ingest-worker.ts b/llm-client/src/ingest-worker.ts new file mode 100644 index 0000000..1420aa2 --- /dev/null +++ b/llm-client/src/ingest-worker.ts @@ -0,0 +1,131 @@ +import type { IngestQueue } from "./queue"; +import { + postContext, + type IngestBody, + type PostContextOpts, + type PostContextResult, +} from "./ingest"; + +export const DEFAULT_BACKOFF: number[] = [ + 1_000, // attempt 1: 1s + 5_000, // attempt 2: 5s + 30_000, // attempt 3: 30s + 5 * 60_000, // attempt 4: 5m + 30 * 60_000, // attempt 5: 30m + 60 * 60_000, // attempt 6+: 1h (cap) +]; + +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]!; +} + +export type PostContextImpl = ( + backendUrl: string, + studentToken: string, + body: IngestBody, + opts?: PostContextOpts, +) => Promise; + +export type WorkerOpts = { + queue: IngestQueue; + backendUrl: string; + studentToken: string; + now?: () => number; + postContextImpl?: PostContextImpl; + backoff?: number[]; +}; + +export type RunSummary = { + processed: number; + created: number; + exists: number; + conflict: number; + fatal: number; + retry: number; +}; + +export async function runOnce( + opts: WorkerOpts, + limit = 50, +): Promise { + const now = (opts.now ?? Date.now)(); + const post: PostContextImpl = opts.postContextImpl ?? postContext; + const backoff = opts.backoff ?? DEFAULT_BACKOFF; + + const ready = opts.queue.pickReady(now, limit); + const summary: RunSummary = { + processed: 0, + created: 0, + exists: 0, + conflict: 0, + fatal: 0, + retry: 0, + }; + + for (const body of ready) { + summary.processed += 1; + const result = await post(opts.backendUrl, opts.studentToken, body); + switch (result.kind) { + case "created": + opts.queue.markSent(body.record_id, "created"); + summary.created += 1; + break; + case "exists": + opts.queue.markSent(body.record_id, "exists"); + summary.exists += 1; + break; + case "conflict": + opts.queue.markTerminal(body.record_id, "conflict", "body mismatch"); + summary.conflict += 1; + break; + case "fatal": + opts.queue.markTerminal( + body.record_id, + "fatal", + `status=${result.status}: ${result.reason}`, + ); + summary.fatal += 1; + break; + case "retryable": { + const prev = opts.queue.statusOf(body.record_id); + const attempts = prev?.attempts ?? 0; + const delay = nextBackoff(attempts, backoff); + opts.queue.markRetry(body.record_id, result.reason, now + delay); + summary.retry += 1; + break; + } + } + } + + return summary; +} + +export type WorkerLoopHandle = { stop: () => Promise }; + +export function startWorkerLoop( + opts: WorkerOpts, + pollMs = 1000, +): WorkerLoopHandle { + let stopped = false; + const done = (async () => { + while (!stopped) { + try { + await runOnce(opts); + } catch (e) { + console.error("ingest worker tick failed:", (e as Error).message); + } + await Bun.sleep(pollMs); + } + })(); + + return { + async stop() { + stopped = true; + await done; + }, + }; +} diff --git a/llm-client/src/ingest.test.ts b/llm-client/src/ingest.test.ts new file mode 100644 index 0000000..ab4d181 --- /dev/null +++ b/llm-client/src/ingest.test.ts @@ -0,0 +1,115 @@ +import { test, expect, afterEach } from "bun:test"; +import type { Server } from "bun"; +import { postContext, type IngestBody, type PostContextResult } from "./ingest"; + +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", + course_code: "ECE4721J", + blob_hash: "deadbeef", + blob_uri: "jbox://zhangsan/aimdware/ECE4721J/x.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("deadbeef"); +}); + +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"); +}); diff --git a/llm-client/src/ingest.ts b/llm-client/src/ingest.ts new file mode 100644 index 0000000..333fb77 --- /dev/null +++ b/llm-client/src/ingest.ts @@ -0,0 +1,75 @@ +import { getProxyForUrl } from "./net"; +import type { FetchLike } from "./proxy"; + +export type IngestBody = { + record_id: string; + course_code: 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 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/main.ts b/llm-client/src/main.ts index 992cbdc..daba7e0 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -1,9 +1,39 @@ #!/usr/bin/env bun import { parseArgs } from "node:util"; -import { readFile } from "node:fs/promises"; -import { loadConfig } from "./config"; +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 "./handler"; import { startServer } from "./server"; +import { IngestQueue } from "./queue"; +import { startWorkerLoop } from "./ingest-worker"; +import type { CaptureResult } from "./capture"; + +const ROUTER_VERSION = "0.0.0"; + +function expandHome(p: string): string { + return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; +} + +function bytesToHex(b: Uint8Array): string { + return Buffer.from(b).toString("hex"); +} + +function buildIngestBody(config: Config, r: CaptureResult) { + return { + record_id: r.record_id, + course_code: config.course, + blob_hash: bytesToHex(r.blob_hash), + blob_uri: `${config.jbox_remote_path}/${r.record_id}.json`, + blob_size: r.blob_size, + ts: r.ts.toISOString(), + router_version: ROUTER_VERSION, + client_meta: { + upstream_type: config.upstream.type, + }, + }; +} async function main() { const { values } = parseArgs({ @@ -30,14 +60,23 @@ async function main() { } const config = loadConfig(yamlText); + const cacheDir = expandHome(config.local_cache_dir); + await mkdir(cacheDir, { recursive: true }); + const queueDb = join(cacheDir, "queue.db"); + + const queue = new IngestQueue(queueDb); const handler = createHandler({ - upstream: { base_url: config.upstream.base_url, api_key: config.upstream.api_key }, + upstream: { + base_url: config.upstream.base_url, + api_key: config.upstream.api_key, + }, onCapture: (result) => { - // Phase 1 stub: replaced by ingest + jbox sync in later phases. - const hex = Buffer.from(result.blob_hash).toString("hex").slice(0, 16); + const body = buildIngestBody(config, result); + queue.enqueue(body, Date.now()); + const hex = bytesToHex(result.blob_hash).slice(0, 16); console.log( - `captured record=${result.record_id} hash=${hex}… size=${result.blob_size}`, + `captured record=${result.record_id} hash=${hex}… size=${result.blob_size} -> queued`, ); }, }); @@ -47,16 +86,28 @@ async function main() { handler, ); + const worker = startWorkerLoop( + { + queue, + backendUrl: config.backend_url, + studentToken: config.student_token, + }, + 1000, + ); + console.log( `aimdware-router listening on http://${handle.hostname}:${handle.port}`, ); console.log(` upstream: ${config.upstream.base_url} (${config.upstream.type})`); console.log(` course: ${config.course}`); console.log(` backend: ${config.backend_url}`); + console.log(` cache: ${cacheDir}`); const shutdown = async (signal: string) => { console.log(`\n${signal} received, stopping`); await handle.stop(); + await worker.stop(); + queue.close(); process.exit(0); }; process.on("SIGINT", () => void shutdown("SIGINT")); diff --git a/llm-client/src/queue.test.ts b/llm-client/src/queue.test.ts new file mode 100644 index 0000000..4642e50 --- /dev/null +++ b/llm-client/src/queue.test.ts @@ -0,0 +1,113 @@ +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"; + +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, + course_code: "ECE4721J", + 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 + pickReady returns the inserted row", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 100); + const ready = q.pickReady(100, 10); + expect(ready).toHaveLength(1); + expect(ready[0]!.record_id).toBe("r1"); + q.close(); +}); + +test("pickReady excludes rows scheduled for the future", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 200); + expect(q.pickReady(100, 10)).toHaveLength(0); + expect(q.pickReady(200, 10)).toHaveLength(1); + q.close(); +}); + +test("markSent removes from pickReady", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + q.markSent("r1", "created"); + expect(q.pickReady(1000, 10)).toHaveLength(0); + q.close(); +}); + +test("markRetry updates attempts + next_attempt_at and keeps pending", () => { + 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 status = q.statusOf("r1"); + expect(status?.attempts).toBe(1); + expect(status?.last_error).toBe("503"); + q.close(); +}); + +test("markTerminal (conflict | fatal) removes 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 (no double insert)", () => { + const { q } = freshDb(); + q.enqueue(body("r1"), 0); + q.enqueue(body("r1"), 100); // second call with same id + const ready = q.pickReady(1000, 10); + expect(ready).toHaveLength(1); + q.close(); +}); + +test("state survives reopening the db file", () => { + const { path, q } = freshDb(); + q.enqueue(body("r1"), 0); + q.enqueue(body("r2"), 50); + q.markSent("r1", "created"); + q.close(); + + const q2 = new IngestQueue(path); + const remaining = q2.pickReady(1000, 10); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.record_id).toBe("r2"); + q2.close(); +}); + +test("pickReady respects limit and orders by next_attempt_at ascending", () => { + 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.record_id)).toEqual(["a", "b"]); + q.close(); +}); diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts new file mode 100644 index 0000000..336000e --- /dev/null +++ b/llm-client/src/queue.ts @@ -0,0 +1,112 @@ +import { Database } from "bun:sqlite"; +import type { IngestBody } from "./ingest"; + +export type QueueState = "pending" | "sent" | "conflict" | "fatal"; + +export type QueueStatus = { + record_id: string; + state: QueueState; + attempts: number; + next_attempt_at: number; + last_error: string | null; +}; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS ingest_queue ( + 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 + ); + CREATE INDEX IF NOT EXISTS ix_pending_ready + ON ingest_queue (state, next_attempt_at); +`; + +export class IngestQueue { + private db: Database; + + constructor(path: string) { + this.db = new Database(path); + this.db.exec("PRAGMA journal_mode = WAL;"); + this.db.exec(SCHEMA); + } + + enqueue(body: IngestBody, nextAttemptAt: number): void { + this.db + .prepare( + `INSERT OR IGNORE INTO ingest_queue + (record_id, body_json, state, attempts, next_attempt_at, created_at) + VALUES (?, ?, 'pending', 0, ?, ?)`, + ) + .run( + body.record_id, + JSON.stringify(body), + nextAttemptAt, + Date.now(), + ); + } + + pickReady(now: number, limit: number): IngestBody[] { + const rows = this.db + .prepare( + `SELECT body_json FROM ingest_queue + WHERE state = 'pending' AND next_attempt_at <= ? + ORDER BY next_attempt_at ASC, created_at ASC + LIMIT ?`, + ) + .all(now, limit) as { body_json: string }[]; + return rows.map((r) => JSON.parse(r.body_json) as IngestBody); + } + + markSent(record_id: string, _kind: "created" | "exists"): void { + this.db + .prepare( + `UPDATE ingest_queue SET state = 'sent', last_error = NULL + WHERE record_id = ?`, + ) + .run(record_id); + } + + markRetry(record_id: string, error: string, nextAttemptAt: number): void { + this.db + .prepare( + `UPDATE ingest_queue + SET attempts = attempts + 1, + next_attempt_at = ?, + last_error = ? + WHERE record_id = ?`, + ) + .run(nextAttemptAt, error, record_id); + } + + markTerminal( + record_id: string, + finalState: "conflict" | "fatal", + error: string, + ): void { + this.db + .prepare( + `UPDATE ingest_queue + SET state = ?, last_error = ? + 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 + FROM ingest_queue WHERE record_id = ?`, + ) + .get(record_id) as QueueStatus | null; + return row ?? undefined; + } + + close(): void { + this.db.close(); + } +} From d0396eb2ce5546b705423ddb0e502d3a437e5cb0 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 11 May 2026 23:30:30 +0800 Subject: [PATCH 06/60] feat(llm-client): client side pipeline --- llm-client/bun.lock | 65 +++++++++ llm-client/package.json | 1 + llm-client/src/config.ts | 1 + llm-client/src/ingest-worker.test.ts | 196 +++++++++++++-------------- llm-client/src/ingest-worker.ts | 158 ++++++++++++--------- llm-client/src/ingest.test.ts | 40 ++++++ llm-client/src/ingest.ts | 44 ++++++ llm-client/src/main.ts | 98 ++++++++++++-- llm-client/src/queue.test.ts | 86 ++++++++---- llm-client/src/queue.ts | 85 ++++++++---- llm-client/src/sync.test.ts | 77 +++++++++++ llm-client/src/sync.ts | 48 +++++++ llm-client/src/util.test.ts | 44 ++++++ llm-client/src/util.ts | 22 +++ 14 files changed, 729 insertions(+), 236 deletions(-) create mode 100644 llm-client/src/sync.test.ts create mode 100644 llm-client/src/sync.ts create mode 100644 llm-client/src/util.test.ts create mode 100644 llm-client/src/util.ts diff --git a/llm-client/bun.lock b/llm-client/bun.lock index 274c988..454e1f4 100644 --- a/llm-client/bun.lock +++ b/llm-client/bun.lock @@ -5,6 +5,7 @@ "": { "name": "aimdware-llm-client", "dependencies": { + "webdav": "^5.10.0", "yaml": "^2.5.1", "zod": "^3.23.8", }, @@ -15,16 +16,80 @@ }, }, "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=="], + "@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=="], + + "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=="], + "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 index 671d381..9e40638 100644 --- a/llm-client/package.json +++ b/llm-client/package.json @@ -11,6 +11,7 @@ "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/aimdware-router" }, "dependencies": { + "webdav": "^5.10.0", "yaml": "^2.5.1", "zod": "^3.23.8" }, diff --git a/llm-client/src/config.ts b/llm-client/src/config.ts index 55af112..b4d4cd2 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -15,6 +15,7 @@ const RawConfigSchema = z.object({ 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"), }); export type Config = z.infer & { diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/ingest-worker.test.ts index 8010a81..9041cde 100644 --- a/llm-client/src/ingest-worker.test.ts +++ b/llm-client/src/ingest-worker.test.ts @@ -3,12 +3,13 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { IngestQueue } from "./queue"; -import type { IngestBody, PostContextResult } from "./ingest"; +import type { IngestBody } from "./ingest"; import { nextBackoff, DEFAULT_BACKOFF, runOnce, - type PostContextImpl, + type Stages, + type StageHandler, } from "./ingest-worker"; const tmpDirs: string[] = []; @@ -32,157 +33,142 @@ afterEach(() => { for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); -test("nextBackoff schedule: 1s, 5s, 30s, 5m, 30m, 1h (cap)", () => { +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(3)).toBe(5 * 60_000); - expect(nextBackoff(4)).toBe(30 * 60_000); expect(nextBackoff(5)).toBe(60 * 60_000); expect(nextBackoff(99)).toBe(60 * 60_000); expect(DEFAULT_BACKOFF.length).toBe(6); }); -test("runOnce: 202 -> markSent", async () => { +test("captured + ingest.advance -> state=ingested", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const fake: PostContextImpl = async () => ({ kind: "created", record_id: "r1" }); - const summary = await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); - expect(summary.created).toBe(1); - expect(q.statusOf("r1")?.state).toBe("sent"); + await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); + expect(q.statusOf("r1")?.state).toBe("ingested"); q.close(); }); -test("runOnce: 200 -> exists also moves to sent", async () => { +test("ingested + sync.advance -> state=synced", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const fake: PostContextImpl = async () => ({ kind: "exists", record_id: "r1" }); - const summary = await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); - expect(summary.exists).toBe(1); - expect(q.statusOf("r1")?.state).toBe("sent"); + q.advance("r1", "ingested", 0); + await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); + expect(q.statusOf("r1")?.state).toBe("synced"); q.close(); }); -test("runOnce: retryable -> markRetry with exponential backoff", async () => { +test("synced + confirm.advance -> state=done", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const fake: PostContextImpl = async () => ({ - kind: "retryable", - status: 503, - reason: "down", - }); - - await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); + 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("pending"); + expect(s.state).toBe("captured"); expect(s.attempts).toBe(1); expect(s.next_attempt_at).toBe(1000 + 1_000); - // simulate time passing, retry again, attempts grows + backoff grows - await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 5000, - }); + 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("runOnce: conflict -> markTerminal(conflict), no retry", async () => { +test("terminal -> markTerminal, no retry", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const fake: PostContextImpl = async () => ({ kind: "conflict" }); - await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, + const fatal: StageHandler = async () => ({ + kind: "terminal", + finalState: "fatal", + reason: "401", }); - expect(q.statusOf("r1")?.state).toBe("conflict"); - expect(q.pickReady(Number.MAX_SAFE_INTEGER, 10)).toHaveLength(0); + 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("runOnce: fatal -> markTerminal(fatal), no retry", async () => { +test("handler-throws is treated as retryable", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const fake: PostContextImpl = async () => ({ - kind: "fatal", - status: 401, - reason: "auth", - }); - await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); - expect(q.statusOf("r1")?.state).toBe("fatal"); - expect(q.statusOf("r1")?.last_error).toContain("auth"); + 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("runOnce: nothing ready -> processed = 0", async () => { +test("nothing ready -> processed=0", async () => { const q = freshQueue(); - q.enqueue(body("r1"), 5000); // scheduled in the future - const fake: PostContextImpl = async () => ({ kind: "created", record_id: "r1" }); - const summary = await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); + q.enqueue(body("r1"), 5000); + const summary = await runOnce({ queue: q, stages: stub3(advance), now: () => 100 }); expect(summary.processed).toBe(0); q.close(); }); -test("runOnce: multiple records in one pass", async () => { +test("dispatches the right handler per record state", async () => { const q = freshQueue(); - q.enqueue(body("a"), 0); + q.enqueue(body("a"), 0); // captured -> ingest q.enqueue(body("b"), 0); - q.enqueue(body("c"), 5000); // not ready - const fake: PostContextImpl = async (_b, _t, body) => ({ - kind: "created", - record_id: body.record_id, - }); - const summary = await runOnce({ - queue: q, - backendUrl: "http://stub", - studentToken: "st", - postContextImpl: fake, - now: () => 1000, - }); - expect(summary.processed).toBe(2); - expect(summary.created).toBe(2); - expect(q.statusOf("a")?.state).toBe("sent"); - expect(q.statusOf("b")?.state).toBe("sent"); - expect(q.statusOf("c")?.state).toBe("pending"); + 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(); }); diff --git a/llm-client/src/ingest-worker.ts b/llm-client/src/ingest-worker.ts index 1420aa2..afeb07c 100644 --- a/llm-client/src/ingest-worker.ts +++ b/llm-client/src/ingest-worker.ts @@ -1,18 +1,8 @@ -import type { IngestQueue } from "./queue"; -import { - postContext, - type IngestBody, - type PostContextOpts, - type PostContextResult, -} from "./ingest"; +import type { IngestQueue, RecordState } from "./queue"; +import type { IngestBody } from "./ingest"; export const DEFAULT_BACKOFF: number[] = [ - 1_000, // attempt 1: 1s - 5_000, // attempt 2: 5s - 30_000, // attempt 3: 30s - 5 * 60_000, // attempt 4: 5m - 30 * 60_000, // attempt 5: 30m - 60 * 60_000, // attempt 6+: 1h (cap) + 1_000, 5_000, 30_000, 5 * 60_000, 30 * 60_000, 60 * 60_000, ]; export function nextBackoff( @@ -23,87 +13,124 @@ export function nextBackoff( return schedule[i] ?? schedule[schedule.length - 1]!; } -export type PostContextImpl = ( - backendUrl: string, - studentToken: string, - body: IngestBody, - opts?: PostContextOpts, -) => Promise; +/** + * 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; - backendUrl: string; - studentToken: string; + stages: Stages; now?: () => number; - postContextImpl?: PostContextImpl; backoff?: number[]; + concurrency?: number; }; export type RunSummary = { processed: number; - created: number; - exists: number; - conflict: number; - fatal: 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 post: PostContextImpl = opts.postContextImpl ?? postContext; + const concurrency = opts.concurrency ?? 4; const backoff = opts.backoff ?? DEFAULT_BACKOFF; const ready = opts.queue.pickReady(now, limit); + const summary: RunSummary = { - processed: 0, - created: 0, - exists: 0, - conflict: 0, - fatal: 0, + processed: ready.length, + advance: 0, retry: 0, + terminal: 0, }; - for (const body of ready) { - summary.processed += 1; - const result = await post(opts.backendUrl, opts.studentToken, body); - switch (result.kind) { - case "created": - opts.queue.markSent(body.record_id, "created"); - summary.created += 1; - break; - case "exists": - opts.queue.markSent(body.record_id, "exists"); - summary.exists += 1; - break; - case "conflict": - opts.queue.markTerminal(body.record_id, "conflict", "body mismatch"); - summary.conflict += 1; - break; - case "fatal": - opts.queue.markTerminal( - body.record_id, - "fatal", - `status=${result.status}: ${result.reason}`, - ); - summary.fatal += 1; - break; - case "retryable": { - const prev = opts.queue.statusOf(body.record_id); - const attempts = prev?.attempts ?? 0; - const delay = nextBackoff(attempts, backoff); - opts.queue.markRetry(body.record_id, result.reason, now + delay); - summary.retry += 1; - break; + // 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" }; } + applyResult(opts.queue, slot.body.record_id, slot.state, result, now, backoff); + 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[], +): void { + switch (result.kind) { + case "advance": { + queue.advance(recordId, nextStateAfter(currentState), now); + break; + } + case "retry": { + const attempts = queue.statusOf(recordId)?.attempts ?? 0; + queue.markRetry(recordId, result.reason, now + nextBackoff(attempts, backoff)); + break; + } + case "terminal": { + queue.markTerminal(recordId, result.finalState, result.reason); + break; + } + } +} + export type WorkerLoopHandle = { stop: () => Promise }; export function startWorkerLoop( @@ -116,12 +143,11 @@ export function startWorkerLoop( try { await runOnce(opts); } catch (e) { - console.error("ingest worker tick failed:", (e as Error).message); + console.error("worker tick failed:", (e as Error).message); } await Bun.sleep(pollMs); } })(); - return { async stop() { stopped = true; diff --git a/llm-client/src/ingest.test.ts b/llm-client/src/ingest.test.ts index ab4d181..9422719 100644 --- a/llm-client/src/ingest.test.ts +++ b/llm-client/src/ingest.test.ts @@ -113,3 +113,43 @@ test("network error -> retryable", async () => { ); expect(r.kind).toBe("retryable"); }); + +// -------- confirmUploaded -------- +import { confirmUploaded } from "./ingest"; + +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/ingest.ts b/llm-client/src/ingest.ts index 333fb77..38ab5e2 100644 --- a/llm-client/src/ingest.ts +++ b/llm-client/src/ingest.ts @@ -26,6 +26,50 @@ 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, diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index daba7e0..76f4d33 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -7,7 +7,10 @@ import { loadConfig, type Config } from "./config"; import { createHandler } from "./handler"; import { startServer } from "./server"; import { IngestQueue } from "./queue"; -import { startWorkerLoop } from "./ingest-worker"; +import { startWorkerLoop, type Stages, type StageHandler } from "./ingest-worker"; +import { postContext, confirmUploaded, type IngestBody } from "./ingest"; +import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; +import { writeAtomic, bytesToHex } from "./util"; import type { CaptureResult } from "./capture"; const ROUTER_VERSION = "0.0.0"; @@ -16,11 +19,7 @@ function expandHome(p: string): string { return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; } -function bytesToHex(b: Uint8Array): string { - return Buffer.from(b).toString("hex"); -} - -function buildIngestBody(config: Config, r: CaptureResult) { +function buildIngestBody(config: Config, r: CaptureResult): IngestBody { return { record_id: r.record_id, course_code: config.course, @@ -29,10 +28,70 @@ function buildIngestBody(config: Config, r: CaptureResult) { blob_size: r.blob_size, ts: r.ts.toISOString(), router_version: ROUTER_VERSION, - client_meta: { - upstream_type: config.upstream.type, - }, + client_meta: { upstream_type: config.upstream.type }, + }; +} + +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 sync: StageHandler = async (body) => { + const path = join(cacheDir, "records", `${body.record_id}.json`); + 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 }; + } + }; + + 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, confirm }; } async function main() { @@ -61,17 +120,29 @@ async function main() { const config = loadConfig(yamlText); const cacheDir = expandHome(config.local_cache_dir); - await mkdir(cacheDir, { recursive: true }); + const recordsDir = join(cacheDir, "records"); + await mkdir(recordsDir, { recursive: true }); const queueDb = join(cacheDir, "queue.db"); const queue = new IngestQueue(queueDb); + const webdavPut = makeWebDAVPut(config.tbox_url); const handler = createHandler({ upstream: { base_url: config.upstream.base_url, api_key: config.upstream.api_key, }, - onCapture: (result) => { + onCapture: async (result) => { + const blobPath = join(recordsDir, `${result.record_id}.json`); + try { + await writeAtomic(blobPath, result.blob_bytes); + } catch (e) { + console.error( + `cache write failed for ${result.record_id}:`, + (e as Error).message, + ); + return; // don't enqueue if we can't preserve the blob + } const body = buildIngestBody(config, result); queue.enqueue(body, Date.now()); const hex = bytesToHex(result.blob_hash).slice(0, 16); @@ -89,8 +160,8 @@ async function main() { const worker = startWorkerLoop( { queue, - backendUrl: config.backend_url, - studentToken: config.student_token, + stages: buildStages(config, cacheDir, webdavPut), + concurrency: 4, }, 1000, ); @@ -101,6 +172,7 @@ async function main() { console.log(` upstream: ${config.upstream.base_url} (${config.upstream.type})`); 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) => { diff --git a/llm-client/src/queue.test.ts b/llm-client/src/queue.test.ts index 4642e50..4df7ce2 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/queue.test.ts @@ -30,44 +30,60 @@ afterEach(() => { for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); -test("enqueue + pickReady returns the inserted row", () => { +test("enqueue starts in 'captured' state", () => { const { q } = freshDb(); - q.enqueue(body("r1"), 100); - const ready = q.pickReady(100, 10); - expect(ready).toHaveLength(1); - expect(ready[0]!.record_id).toBe("r1"); + q.enqueue(body("r1"), 0); + expect(q.statusOf("r1")?.state).toBe("captured"); q.close(); }); -test("pickReady excludes rows scheduled for the future", () => { +test("pickReady returns active records past their next_attempt_at", () => { const { q } = freshDb(); - q.enqueue(body("r1"), 200); - expect(q.pickReady(100, 10)).toHaveLength(0); - expect(q.pickReady(200, 10)).toHaveLength(1); + 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("markSent removes from pickReady", () => { +test("advance moves through the lifecycle and resets attempts", () => { const { q } = freshDb(); q.enqueue(body("r1"), 0); - q.markSent("r1", "created"); + + 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 updates attempts + next_attempt_at and keeps pending", () => { +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 status = q.statusOf("r1"); - expect(status?.attempts).toBe(1); - expect(status?.last_error).toBe("503"); + 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 | fatal) removes from pickReady", () => { +test("markTerminal: conflict and fatal remove from pickReady", () => { const { q } = freshDb(); q.enqueue(body("a"), 0); q.enqueue(body("b"), 0); @@ -79,35 +95,49 @@ test("markTerminal (conflict | fatal) removes from pickReady", () => { q.close(); }); -test("enqueue is idempotent on duplicate record_id (no double insert)", () => { +test("enqueue is idempotent on duplicate record_id", () => { const { q } = freshDb(); q.enqueue(body("r1"), 0); - q.enqueue(body("r1"), 100); // second call with same id - const ready = q.pickReady(1000, 10); - expect(ready).toHaveLength(1); + q.enqueue(body("r1"), 100); + expect(q.pickReady(1000, 10)).toHaveLength(1); q.close(); }); test("state survives reopening the db file", () => { const { path, q } = freshDb(); - q.enqueue(body("r1"), 0); - q.enqueue(body("r2"), 50); - q.markSent("r1", "created"); + 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); - const remaining = q2.pickReady(1000, 10); - expect(remaining).toHaveLength(1); - expect(remaining[0]!.record_id).toBe("r2"); + expect(q2.statusOf("a")?.state).toBe("synced"); + expect(q2.statusOf("b")?.state).toBe("captured"); q2.close(); }); -test("pickReady respects limit and orders by next_attempt_at ascending", () => { +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.record_id)).toEqual(["a", "b"]); + 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(); }); diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index 336000e..a9acfdc 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -1,18 +1,45 @@ import { Database } from "bun:sqlite"; import type { IngestBody } from "./ingest"; -export type QueueState = "pending" | "sent" | "conflict" | "fatal"; +/** + * 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: QueueState; + state: RecordState; attempts: number; next_attempt_at: number; last_error: string | null; }; +export type ReadyRecord = { + body: IngestBody; + state: RecordState; +}; + const SCHEMA = ` - CREATE TABLE IF NOT EXISTS ingest_queue ( + CREATE TABLE IF NOT EXISTS outbox ( record_id TEXT PRIMARY KEY, body_json TEXT NOT NULL, state TEXT NOT NULL, @@ -21,8 +48,8 @@ const SCHEMA = ` last_error TEXT, created_at INTEGER NOT NULL ); - CREATE INDEX IF NOT EXISTS ix_pending_ready - ON ingest_queue (state, next_attempt_at); + CREATE INDEX IF NOT EXISTS ix_outbox_active + ON outbox (state, next_attempt_at); `; export class IngestQueue { @@ -37,43 +64,50 @@ export class IngestQueue { enqueue(body: IngestBody, nextAttemptAt: number): void { this.db .prepare( - `INSERT OR IGNORE INTO ingest_queue + `INSERT OR IGNORE INTO outbox (record_id, body_json, state, attempts, next_attempt_at, created_at) - VALUES (?, ?, 'pending', 0, ?, ?)`, + VALUES (?, ?, 'captured', 0, ?, ?)`, ) - .run( - body.record_id, - JSON.stringify(body), - nextAttemptAt, - Date.now(), - ); + .run(body.record_id, JSON.stringify(body), nextAttemptAt, Date.now()); } - pickReady(now: number, limit: number): IngestBody[] { + pickReady(now: number, limit: number): ReadyRecord[] { + const placeholders = ACTIVE_STATES.map(() => "?").join(","); const rows = this.db .prepare( - `SELECT body_json FROM ingest_queue - WHERE state = 'pending' AND next_attempt_at <= ? + `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(now, limit) as { body_json: string }[]; - return rows.map((r) => JSON.parse(r.body_json) as IngestBody); + .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, + })); } - markSent(record_id: string, _kind: "created" | "exists"): void { + /** + * Advance to the next state on success. Resets attempts + clears error. + */ + advance(record_id: string, newState: RecordState, nextAttemptAt: number): void { this.db .prepare( - `UPDATE ingest_queue SET state = 'sent', last_error = NULL + `UPDATE outbox + SET state = ?, attempts = 0, next_attempt_at = ?, last_error = NULL WHERE record_id = ?`, ) - .run(record_id); + .run(newState, nextAttemptAt, record_id); } + /** + * Retry the current stage. Increments attempts, sets next_attempt_at, + * records error. State unchanged. + */ markRetry(record_id: string, error: string, nextAttemptAt: number): void { this.db .prepare( - `UPDATE ingest_queue + `UPDATE outbox SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? @@ -82,6 +116,9 @@ export class IngestQueue { .run(nextAttemptAt, error, record_id); } + /** + * Terminal failure (conflict | fatal). No further work attempted. + */ markTerminal( record_id: string, finalState: "conflict" | "fatal", @@ -89,7 +126,7 @@ export class IngestQueue { ): void { this.db .prepare( - `UPDATE ingest_queue + `UPDATE outbox SET state = ?, last_error = ? WHERE record_id = ?`, ) @@ -100,7 +137,7 @@ export class IngestQueue { const row = this.db .prepare( `SELECT record_id, state, attempts, next_attempt_at, last_error - FROM ingest_queue WHERE record_id = ?`, + FROM outbox WHERE record_id = ?`, ) .get(record_id) as QueueStatus | null; return row ?? undefined; diff --git a/llm-client/src/sync.test.ts b/llm-client/src/sync.test.ts new file mode 100644 index 0000000..4789bb8 --- /dev/null +++ b/llm-client/src/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/sync.ts b/llm-client/src/sync.ts new file mode 100644 index 0000000..0f8a867 --- /dev/null +++ b/llm-client/src/sync.ts @@ -0,0 +1,48 @@ +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 ?? ""}` }; + } +} + +/** + * Build a webdav-package-backed PUT function bound to a Tbox URL. + */ +export function makeWebDAVPut(tboxUrl: string): WebDAVPutLike { + const client: WebDAVClient = createClient(tboxUrl); + return async (path, data) => { + // webdav expects Buffer | string | Readable. Convert Uint8Array. + 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/util.test.ts b/llm-client/src/util.test.ts new file mode 100644 index 0000000..27466c4 --- /dev/null +++ b/llm-client/src/util.test.ts @@ -0,0 +1,44 @@ +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 } 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(); +}); diff --git a/llm-client/src/util.ts b/llm-client/src/util.ts new file mode 100644 index 0000000..0c6e2d2 --- /dev/null +++ b/llm-client/src/util.ts @@ -0,0 +1,22 @@ +import { rename } from "node:fs/promises"; + +/** + * 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. + */ +export async function writeAtomic( + path: string, + data: Uint8Array, +): Promise { + const tmp = `${path}.tmp.${crypto.randomUUID()}`; + await Bun.write(tmp, data); + await rename(tmp, path); +} + +export function bytesToHex(b: Uint8Array): string { + return Buffer.from(b).toString("hex"); +} From b767ab7688010152efdb3a1b81ea050f787cabdc Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 12 May 2026 18:35:56 +0800 Subject: [PATCH 07/60] feat: token-hash auth mechanism + client-side secret redaction --- llm-client/src/main.test.ts | 78 ++++++++++++++++++++++++++++++++++++- llm-client/src/main.ts | 4 +- llm-client/src/util.test.ts | 17 +++++++- llm-client/src/util.ts | 16 ++++++++ wiki/admin-script.md | 37 ++++++++++++++++-- wiki/backend.md | 27 +++++++++++++ wiki/llm-client.md | 14 +++++++ wiki/threat-model.md | 9 +++++ 8 files changed, 196 insertions(+), 6 deletions(-) diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts index 291a57e..0e9a2a6 100644 --- a/llm-client/src/main.test.ts +++ b/llm-client/src/main.test.ts @@ -38,6 +38,18 @@ function pickFreePort(): Promise { }); } +async function streamToString(s: ReadableStream | undefined): Promise { + if (!s) return ""; + const reader = s.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); +} + test("main: serves /healthz and proxies a chat completion end-to-end", async () => { const fakeUpstream = Bun.serve({ port: 0, @@ -70,7 +82,7 @@ backend_url: http://127.0.0.1:1 const proc = spawn({ cmd: ["bun", "run", "src/main.ts", "--config", configPath], - cwd: process.cwd(), + cwd: import.meta.dir + "/..", stdout: "pipe", stderr: "pipe", }); @@ -91,3 +103,67 @@ backend_url: http://127.0.0.1:1 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); + + // Use distinctive, long, unmistakable test secrets + 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 +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); + + try { + await waitForPort(routerPort); + + // Fire one chat to exercise the capture / ingest logging path too + 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(); + const stdout = await streamToString(proc.stdout as ReadableStream); + const stderr = await streamToString(proc.stderr as ReadableStream); + const combined = stdout + stderr; + + expect(combined).not.toContain(STUDENT); + expect(combined).not.toContain(APIKEY); + // sanity: the redacted versions should be present (proves we did log + // *something* about the token, we just redacted it) + 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 index 76f4d33..b5cb79e 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -10,7 +10,7 @@ import { IngestQueue } from "./queue"; import { startWorkerLoop, type Stages, type StageHandler } from "./ingest-worker"; import { postContext, confirmUploaded, type IngestBody } from "./ingest"; import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; -import { writeAtomic, bytesToHex } from "./util"; +import { writeAtomic, bytesToHex, redactToken } from "./util"; import type { CaptureResult } from "./capture"; const ROUTER_VERSION = "0.0.0"; @@ -170,6 +170,8 @@ async function main() { `aimdware-router listening on http://${handle.hostname}:${handle.port}`, ); console.log(` upstream: ${config.upstream.base_url} (${config.upstream.type})`); + console.log(` upstream key: ${redactToken(config.upstream.api_key)}`); + 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}`); diff --git a/llm-client/src/util.test.ts b/llm-client/src/util.test.ts index 27466c4..7fb004e 100644 --- a/llm-client/src/util.test.ts +++ b/llm-client/src/util.test.ts @@ -2,7 +2,7 @@ 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 } from "./util"; +import { writeAtomic, redactToken } from "./util"; const tmpDirs: string[] = []; function freshDir(): string { @@ -42,3 +42,18 @@ test("writeAtomic on a non-existent directory throws (does not silently mkdir)", 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 index 0c6e2d2..757afa0 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -20,3 +20,19 @@ export async function writeAtomic( 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)}…`; +} + diff --git a/wiki/admin-script.md b/wiki/admin-script.md index 68a8934..51a59a7 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -30,19 +30,50 @@ aimdware-admin enrol --course ECE4721J --user zhangsan --role student aimdware-admin enrol-bulk --course ECE4721J --csv roster.csv aimdware-admin token issue --user zhangsan # one token per student; not course-scoped aimdware-admin token revoke --user zhangsan +aimdware-admin token rotate --user zhangsan # atomic revoke + issue aimdware-admin records list --course ECE4721J [--student zhangsan] [--since 2026-04-01] aimdware-admin records show --id aimdware-admin records fetch --id [--verify] ``` -- `token issue` prints the plaintext student token once; hand it - directly to the student. The token is not course-scoped; the student's - router config supplies the active course per request. - `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. +## Token lifecycle + +The plaintext token only exists at issue time. Backend stores +`sha256(plaintext)`; the student's router config is the only place the +plaintext lives long-term. + +``` +issue: + plaintext = "st_" + crypto.randomBytes(32).base64url + hash = sha256(plaintext) + prefix = plaintext[:8] # human ID, e.g. "st_K9aB" + + with atomic transaction: + revoke any existing 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 + # ↳ hand to student through any channel + +revoke: + UPDATE student_tokens SET revoked_at = NOW() + WHERE user_id = uid AND revoked_at IS NULL + +rotate: + atomic { revoke; issue } + print new plaintext; student updates router config +``` + +`token issue` and `token rotate` print the plaintext exactly once and +nowhere else. If the student loses it, only `rotate` recovers (we +cannot read the old one back). + ## Project layout ``` diff --git a/wiki/backend.md b/wiki/backend.md index f4e4895..1d198b2 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -128,6 +128,30 @@ Notes: - `blob_size` uses `BIGINT` (not `INTEGER`) — INTEGER tops out at 2 GB and runaway blobs should fail loudly, not silently truncate. +## Token validation + +The plaintext token never lives on the backend. Each ingest request: + +``` +1. plaintext = request.headers["Authorization"].removeprefix("Bearer ") +2. hash = sha256(plaintext) +3. row = SELECT user_id FROM student_tokens + WHERE token_hash = hash AND revoked_at IS NULL +4. if not row: return 401 +5. inject user_id into request.context +``` + +Implementation notes: + +- The DB equality on `token_hash` (a fixed-length `bytea`) is constant-time + for our purposes; no need for `hmac.compare_digest` after the lookup + succeeds (the row matched by definition). +- `Authorization` is added to the logger's redact set, so plaintext + never appears in stdout / file logs. +- We use plain sha256, not argon2 / bcrypt. Tokens are 256-bit random + secrets — search space is 2²⁵⁶, rainbow tables don't apply. Slow + hashing exists to penalize weak passwords; it's not needed here. + ## Security - **Token → student attribution.** The token identifies the student; @@ -138,5 +162,8 @@ Notes: token writes at most fake records for that student's enrolled courses. - **No content on the backend.** Postgres holds metadata + hash + URI. A full DB compromise yields no student work. +- **DB compromise leaks no usable token.** Only `sha256(token)` is + stored. Rotation is the response to a suspected leak — see + [admin-script.md](admin-script.md). - **Constant-time compare** on student-token validation; tokens never in logs. diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 5be1dde..7b452fd 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -31,6 +31,20 @@ and `course`), or a single instance with multi-course config (post-v1). The router holds no jbox credential — auth lives inside Tbox. +### Token storage and rotation + +The `student_token` plaintext is **the only long-lived copy** of the +credential. The backend stores only `sha256(token)`. Implications: + +- `config.json` is written with mode `600`; the router never logs the + plaintext (`student_token` is on the redact list). +- If the laptop / config file is compromised, treat the token as leaked + and ask the TT to run `aimdware-admin token rotate --user `. + This invalidates the leaked token at the next backend request and + prints a new plaintext to paste into config. +- If the student loses the token, the same rotate flow is the only + recovery — we cannot read back the old one from the backend. + ## Output OpenAI-compatible Chat Completions at `http://127.0.0.1:`. Coding diff --git a/wiki/threat-model.md b/wiki/threat-model.md index 02954c3..6046797 100644 --- a/wiki/threat-model.md +++ b/wiki/threat-model.md @@ -23,6 +23,15 @@ Under these, no client-side software can guarantee monitoring. metadata + hash + URI. A full DB compromise yields no student work. 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 From 45c9c97dbfdebe9a83255f8c0d6ee7eca85aaf16 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 12 May 2026 19:09:21 +0800 Subject: [PATCH 08/60] feat(llm-client): cache eviction (TTL) + interruptible worker sleeps --- llm-client/src/eviction.test.ts | 164 ++++++++++++++++++++++++++++++++ llm-client/src/eviction.ts | 79 +++++++++++++++ llm-client/src/ingest-worker.ts | 6 +- llm-client/src/main.test.ts | 42 ++++---- llm-client/src/main.ts | 4 + llm-client/src/queue.ts | 37 ++++++- llm-client/src/util.ts | 31 ++++++ 7 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 llm-client/src/eviction.test.ts create mode 100644 llm-client/src/eviction.ts diff --git a/llm-client/src/eviction.test.ts b/llm-client/src/eviction.test.ts new file mode 100644 index 0000000..9c3896a --- /dev/null +++ b/llm-client/src/eviction.test.ts @@ -0,0 +1,164 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { IngestQueue } from "./queue"; +import type { IngestBody } from "./ingest"; +import { runEvictionOnce } 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(id: string): IngestBody { + return { + record_id: id, + course_code: "ECE4721J", + blob_hash: "h", + blob_uri: `jbox://x/${id}.json`, + blob_size: 1, + ts: "2026-05-12T00:00:00.000Z", + router_version: "0.0.0", + }; +} + +function setupDoneRecord( + q: IngestQueue, + recordsDir: string, + id: string, + createdMs: number, +) { + // bypass enqueue's now() by inserting raw, then advance + q.enqueue(body(id), 0); + q.advance(id, "ingested", 0); + q.advance(id, "synced", 0); + q.advance(id, "done", 0); + // We need to backdate created_at; use raw sql for the test fixture. + const Database = require("bun:sqlite").Database; + const raw = new Database(join(recordsDir, "..", "queue.db")); + raw.exec(`UPDATE outbox SET created_at = ${createdMs} WHERE record_id = '${id}'`); + raw.close(); + writeFileSync(join(recordsDir, `${id}.json`), `payload-${id}`); +} + +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +test("runEvictionOnce deletes file + marks evicted for done records older than ttl", async () => { + const { cacheDir, recordsDir, q } = fresh(); + const NOW = 100_000_000; + const TTL_MS = 7 * 24 * 3600 * 1000; + setupDoneRecord(q, recordsDir, "old", NOW - TTL_MS - 1000); // older than ttl + setupDoneRecord(q, recordsDir, "fresh", NOW - 1000); // within ttl + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL_MS, + }); + + expect(summary.evicted).toBe(1); + expect(existsSync(join(recordsDir, "old.json"))).toBe(false); + expect(existsSync(join(recordsDir, "fresh.json"))).toBe(true); + expect(q.isEvicted("old")).toBe(true); + expect(q.isEvicted("fresh")).toBe(false); + q.close(); +}); + +test("runEvictionOnce ignores records not in 'done' state", async () => { + const { cacheDir, recordsDir, q } = fresh(); + const NOW = 100_000_000; + const TTL_MS = 7 * 24 * 3600 * 1000; + // captured / ingested / synced — even if old, do not evict + q.enqueue(body("a"), 0); + q.enqueue(body("b"), 0); + q.advance("b", "ingested", 0); + q.enqueue(body("c"), 0); + q.advance("c", "ingested", 0); + q.advance("c", "synced", 0); + for (const id of ["a", "b", "c"]) { + const raw = new (require("bun:sqlite").Database)(join(cacheDir, "queue.db")); + raw.exec(`UPDATE outbox SET created_at = ${NOW - TTL_MS - 1000} WHERE record_id = '${id}'`); + raw.close(); + writeFileSync(join(recordsDir, `${id}.json`), "x"); + } + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL_MS, + }); + + expect(summary.evicted).toBe(0); + for (const id of ["a", "b", "c"]) { + expect(existsSync(join(recordsDir, `${id}.json`))).toBe(true); + } + q.close(); +}); + +test("runEvictionOnce idempotent when file is already missing", async () => { + const { cacheDir, recordsDir, q } = fresh(); + const NOW = 100_000_000; + const TTL_MS = 7 * 24 * 3600 * 1000; + setupDoneRecord(q, recordsDir, "ghost", NOW - TTL_MS - 1000); + rmSync(join(recordsDir, "ghost.json")); // simulate the file having been removed by hand + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL_MS, + }); + + // Still counted as evicted; the row's cache_evicted flag is what we trust. + expect(summary.evicted).toBe(1); + expect(q.isEvicted("ghost")).toBe(true); + q.close(); +}); + +test("runEvictionOnce skips records already marked cache_evicted", async () => { + const { cacheDir, recordsDir, q } = fresh(); + const NOW = 100_000_000; + const TTL_MS = 7 * 24 * 3600 * 1000; + setupDoneRecord(q, recordsDir, "already", NOW - TTL_MS - 1000); + q.markEvicted("already"); + rmSync(join(recordsDir, "already.json")); + + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL_MS, + }); + + expect(summary.evicted).toBe(0); + q.close(); +}); + +test("runEvictionOnce respects limit", async () => { + const { cacheDir, recordsDir, q } = fresh(); + const NOW = 100_000_000; + const TTL_MS = 7 * 24 * 3600 * 1000; + for (let i = 0; i < 5; i++) { + setupDoneRecord(q, recordsDir, `r${i}`, NOW - TTL_MS - 1000 - i); + } + const summary = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL_MS, + limit: 2, + }); + expect(summary.evicted).toBe(2); + q.close(); +}); diff --git a/llm-client/src/eviction.ts b/llm-client/src/eviction.ts new file mode 100644 index 0000000..8505f62 --- /dev/null +++ b/llm-client/src/eviction.ts @@ -0,0 +1,79 @@ +import { join } from "node:path"; +import { unlink } from "node:fs/promises"; +import type { IngestQueue } from "./queue"; +import { StoppableSleep } from "./util"; + +export type EvictionOpts = { + queue: IngestQueue; + cacheDir: string; + now?: () => number; + ttlMs?: number; + limit?: number; +}; + +export type EvictionSummary = { + evicted: number; +}; + +const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000; +const DEFAULT_LIMIT = 500; + +/** + * One eviction pass. Deletes records/{id}.json for done records older than + * ttlMs, then marks the queue row as cache_evicted = 1. Idempotent: a + * missing file still gets the row marked. + * + * The queue row itself is never deleted — it remains 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; + + const ids = opts.queue.findEvictable(threshold, limit); + for (const id of ids) { + try { + await unlink(join(opts.cacheDir, "records", `${id}.json`)); + } catch (e) { + // ENOENT etc. — file already gone, still consider it evicted. + const code = (e as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + console.warn(`unlink failed for ${id}:`, (e as Error).message); + } + } + opts.queue.markEvicted(id); + } + return { evicted: ids.length }; +} + +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/ingest-worker.ts b/llm-client/src/ingest-worker.ts index afeb07c..4a1963c 100644 --- a/llm-client/src/ingest-worker.ts +++ b/llm-client/src/ingest-worker.ts @@ -1,5 +1,6 @@ import type { IngestQueue, RecordState } from "./queue"; import type { IngestBody } from "./ingest"; +import { StoppableSleep } from "./util"; export const DEFAULT_BACKOFF: number[] = [ 1_000, 5_000, 30_000, 5 * 60_000, 30 * 60_000, 60 * 60_000, @@ -137,6 +138,7 @@ export function startWorkerLoop( opts: WorkerOpts, pollMs = 1000, ): WorkerLoopHandle { + const sleeper = new StoppableSleep(); let stopped = false; const done = (async () => { while (!stopped) { @@ -145,12 +147,14 @@ export function startWorkerLoop( } catch (e) { console.error("worker tick failed:", (e as Error).message); } - await Bun.sleep(pollMs); + if (stopped) break; + await sleeper.sleep(pollMs); } })(); return { async stop() { stopped = true; + sleeper.stop(); await done; }, }; diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts index 0e9a2a6..d8820f1 100644 --- a/llm-client/src/main.test.ts +++ b/llm-client/src/main.test.ts @@ -38,18 +38,6 @@ function pickFreePort(): Promise { }); } -async function streamToString(s: ReadableStream | undefined): Promise { - if (!s) return ""; - const reader = s.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); -} - test("main: serves /healthz and proxies a chat completion end-to-end", async () => { const fakeUpstream = Bun.serve({ port: 0, @@ -114,7 +102,6 @@ test("main: never prints plaintext student_token or upstream api_key", async () const tmp = await mkdtemp(join(tmpdir(), "aimdware-redact-")); tmpDirs.push(tmp); - // Use distinctive, long, unmistakable test secrets const STUDENT = "st_DO_NOT_LOG_ME_THIS_IS_LONG_AND_OBVIOUS_zzzz"; const APIKEY = "sk-DO_NOT_LOG_ME_EITHER_zzzz"; @@ -141,10 +128,24 @@ backend_url: http://127.0.0.1:1 }); 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); - // Fire one chat to exercise the capture / ingest logging path too await fetch(`http://127.0.0.1:${routerPort}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, @@ -153,14 +154,17 @@ backend_url: http://127.0.0.1:1 await Bun.sleep(100); proc.kill(); - const stdout = await streamToString(proc.stdout as ReadableStream); - const stderr = await streamToString(proc.stderr as ReadableStream); - const combined = stdout + stderr; + 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 versions should be present (proves we did log - // *something* about the token, we just redacted it) + // 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 { diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index b5cb79e..5b99114 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -11,6 +11,7 @@ import { startWorkerLoop, type Stages, type StageHandler } from "./ingest-worker import { postContext, confirmUploaded, type IngestBody } from "./ingest"; import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; import { writeAtomic, bytesToHex, redactToken } from "./util"; +import { startEvictionLoop } from "./eviction"; import type { CaptureResult } from "./capture"; const ROUTER_VERSION = "0.0.0"; @@ -166,6 +167,8 @@ async function main() { 1000, ); + const eviction = startEvictionLoop({ queue, cacheDir }); + console.log( `aimdware-router listening on http://${handle.hostname}:${handle.port}`, ); @@ -181,6 +184,7 @@ async function main() { console.log(`\n${signal} received, stopping`); await handle.stop(); await worker.stop(); + await eviction.stop(); queue.close(); process.exit(0); }; diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index a9acfdc..f0a8d85 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -46,10 +46,13 @@ const SCHEMA = ` attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL, last_error TEXT, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + cache_evicted INTEGER NOT NULL DEFAULT 0 ); 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); `; export class IngestQueue { @@ -143,6 +146,38 @@ export class IngestQueue { return row ?? undefined; } + /** + * Done records whose cache file is older than the threshold and not yet + * evicted. Cap by limit. + */ + findEvictable(olderThanCreatedAt: number, limit: number): string[] { + const rows = this.db + .prepare( + `SELECT record_id FROM outbox + WHERE state = 'done' + AND cache_evicted = 0 + AND created_at < ? + ORDER BY created_at ASC + LIMIT ?`, + ) + .all(olderThanCreatedAt, limit) as { record_id: string }[]; + return rows.map((r) => r.record_id); + } + + markEvicted(record_id: string): void { + this.db + .prepare(`UPDATE outbox SET cache_evicted = 1 WHERE record_id = ?`) + .run(record_id); + } + + /** Did we already free this record's cache file? */ + 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/util.ts b/llm-client/src/util.ts index 757afa0..b92341e 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -36,3 +36,34 @@ export function redactToken(token: string | undefined | null): string { 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?.(); + } +} + + From 8bb42a941eab4738a9e6778dcb63b5409bc65f65 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 12 May 2026 19:28:43 +0800 Subject: [PATCH 09/60] feat(llm-client): cache eviction (TTL) + interruptible worker sleeps --- llm-client/src/eviction.ts | 6 +++--- llm-client/src/queue.ts | 30 ++++++++++++++---------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/llm-client/src/eviction.ts b/llm-client/src/eviction.ts index 8505f62..44c1be8 100644 --- a/llm-client/src/eviction.ts +++ b/llm-client/src/eviction.ts @@ -19,9 +19,9 @@ const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000; const DEFAULT_LIMIT = 500; /** - * One eviction pass. Deletes records/{id}.json for done records older than - * ttlMs, then marks the queue row as cache_evicted = 1. Idempotent: a - * missing file still gets the row marked. + * Run one eviction pass. Deletes `records/{id}.json` for done records + * older than `ttlMs`, then marks the queue row as `cache_evicted = 1`. + * Idempotent: a missing file still gets the row marked. * * The queue row itself is never deleted — it remains as a local audit * trail. Eventual queue cleanup is a separate admin concern. diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index f0a8d85..22a524a 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -38,8 +38,8 @@ export type ReadyRecord = { state: RecordState; }; -const SCHEMA = ` - CREATE TABLE IF NOT EXISTS outbox ( +const SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS outbox ( record_id TEXT PRIMARY KEY, body_json TEXT NOT NULL, state TEXT NOT NULL, @@ -48,20 +48,20 @@ const SCHEMA = ` last_error TEXT, created_at INTEGER NOT NULL, cache_evicted INTEGER NOT NULL DEFAULT 0 - ); - 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); -`; + )`, + `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)`, +]; export class IngestQueue { private db: Database; constructor(path: string) { this.db = new Database(path); - this.db.exec("PRAGMA journal_mode = WAL;"); - this.db.exec(SCHEMA); + this.db.run("PRAGMA journal_mode = WAL"); + for (const sql of SCHEMA_STATEMENTS) this.db.run(sql); } enqueue(body: IngestBody, nextAttemptAt: number): void { @@ -119,9 +119,7 @@ export class IngestQueue { .run(nextAttemptAt, error, record_id); } - /** - * Terminal failure (conflict | fatal). No further work attempted. - */ + /** Mark a terminal failure (conflict | fatal). No further work attempted. */ markTerminal( record_id: string, finalState: "conflict" | "fatal", @@ -147,8 +145,8 @@ export class IngestQueue { } /** - * Done records whose cache file is older than the threshold and not yet - * evicted. Cap by limit. + * Return done records whose cache file is older than the threshold and + * not yet evicted. Capped by `limit`. */ findEvictable(olderThanCreatedAt: number, limit: number): string[] { const rows = this.db @@ -170,7 +168,7 @@ export class IngestQueue { .run(record_id); } - /** Did we already free this record's cache file? */ + /** 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 = ?`) From 3dad1840c57e923847b36330958e0ccc72e0d023 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 06:27:30 +0800 Subject: [PATCH 10/60] feat(backend): scaffold + ingest API + token-hash auth --- backend/.gitignore | 11 + backend/pyproject.toml | 38 + backend/src/aimdware_backend/__init__.py | 0 backend/src/aimdware_backend/auth.py | 63 ++ backend/src/aimdware_backend/db.py | 33 + backend/src/aimdware_backend/main.py | 15 + backend/src/aimdware_backend/models.py | 94 ++ .../src/aimdware_backend/routes/__init__.py | 0 backend/src/aimdware_backend/routes/ingest.py | 141 +++ backend/src/aimdware_backend/settings.py | 13 + backend/tests/conftest.py | 34 + backend/tests/test_auth.py | 135 +++ backend/tests/test_ingest.py | 225 +++++ backend/tests/test_models.py | 142 +++ backend/uv.lock | 809 ++++++++++++++++++ 15 files changed, 1753 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/pyproject.toml create mode 100644 backend/src/aimdware_backend/__init__.py create mode 100644 backend/src/aimdware_backend/auth.py create mode 100644 backend/src/aimdware_backend/db.py create mode 100644 backend/src/aimdware_backend/main.py create mode 100644 backend/src/aimdware_backend/models.py create mode 100644 backend/src/aimdware_backend/routes/__init__.py create mode 100644 backend/src/aimdware_backend/routes/ingest.py create mode 100644 backend/src/aimdware_backend/settings.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_ingest.py create mode 100644 backend/tests/test_models.py create mode 100644 backend/uv.lock diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..0bd6d6a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +*.pyc +.mypy_cache/ +.ruff_cache/ +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..66fef9c --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,38 @@ +[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", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "httpx>=0.27", + "anyio>=4.6", +] + +[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/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/auth.py b/backend/src/aimdware_backend/auth.py new file mode 100644 index 0000000..b4b10bb --- /dev/null +++ b/backend/src/aimdware_backend/auth.py @@ -0,0 +1,63 @@ +"""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..6c80e6d --- /dev/null +++ b/backend/src/aimdware_backend/db.py @@ -0,0 +1,33 @@ +"""Database engine + session dependency.""" +from __future__ import annotations + +from typing 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/main.py b/backend/src/aimdware_backend/main.py new file mode 100644 index 0000000..fc1258c --- /dev/null +++ b/backend/src/aimdware_backend/main.py @@ -0,0 +1,15 @@ +"""FastAPI app factory.""" +from __future__ import annotations + +from fastapi import FastAPI + +from aimdware_backend.routes import ingest + + +def create_app() -> FastAPI: + app = FastAPI(title="aimdware-backend") + app.include_router(ingest.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..7f806f2 --- /dev/null +++ b/backend/src/aimdware_backend/models.py @@ -0,0 +1,94 @@ +"""SQLModel schemas for the aimdware backend.""" +from __future__ import annotations + +from datetime import datetime, timezone +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 + + +def utcnow() -> datetime: + """Return the current UTC time (timezone-aware).""" + return datetime.now(timezone.utc) + + +class Role(str, Enum): + student = "student" + admin = "admin" + + +class BlobStatus(str, Enum): + 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) + 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: Optional[datetime] = 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" + + 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=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: Optional[datetime] = None 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/ingest.py b/backend/src/aimdware_backend/routes/ingest.py new file mode 100644 index 0000000..997baa9 --- /dev/null +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -0,0 +1,141 @@ +"""Ingest API — the only HTTP surface the student router talks to.""" +from __future__ import annotations + +from datetime import 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 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 + course_code: str + blob_hash: str # hex-encoded sha256 + blob_uri: str + blob_size: int = Field(ge=0) + 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 _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) + + digest = bytes.fromhex(body.blob_hash) + existing = session.get(ContextRecord, body.record_id) + if existing is not None: + same = ( + existing.blob_hash == digest + and existing.blob_uri == body.blob_uri + and existing.blob_size == body.blob_size + and existing.user_id == user.id + and existing.course_id == course.id + ) + if not same: + raise HTTPException( + status_code=409, + detail="record_id matches existing row with different content", + ) + 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, + 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) + session.commit() + 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. + """ + 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..3b5d6fb --- /dev/null +++ b/backend/src/aimdware_backend/settings.py @@ -0,0 +1,13 @@ +"""Process-wide settings, populated from env vars.""" +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="AIMDWARE_", case_sensitive=False) + + database_url: str = "sqlite:///./aimdware.db" + + +settings = Settings() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7bdef9e --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared pytest fixtures.""" +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine +from sqlalchemy import Engine +from sqlalchemy.pool import StaticPool + +# 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) + return e + + +@pytest.fixture +def session(engine: Engine): + with Session(engine) as s: + yield s diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..2efe6e0 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,135 @@ +"""TDD: token-hash bearer auth.""" +from __future__ import annotations + +import hashlib + +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: User = Depends(authenticate_student)): # noqa: ANN001 + 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, timezone + + for row in session.exec( # noqa: SLF001 — test + __import__("sqlmodel").select(StudentToken) + ).all(): + row.revoked_at = datetime.now(timezone.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, timezone + + # old token, revoked + session.add( + StudentToken( + user_id=user.id, + token_hash=_hash("st_OLD_REVOKED"), + prefix="st_OLD_R", + revoked_at=datetime.now(timezone.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..064b2f1 --- /dev/null +++ b/backend/tests/test_ingest.py @@ -0,0 +1,225 @@ +"""TDD: ingest endpoints.""" +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +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: + base = { + "record_id": str(uuid4()), + "course_code": course_code, + "blob_hash": "ab" * 32, + "blob_uri": "aimdware/ECE4721J/abc.json", + "blob_size": 1234, + "model": "gpt-4o-mini", + "prompt_tokens": 10, + "completion_tokens": 20, + "ts": datetime.now(timezone.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 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..03bf09e --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,142 @@ +"""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, + model="gpt-4o-mini", + prompt_tokens=10, + completion_tokens=20, + router_version="0.0.0", + client_meta={"agent": "cline"}, + blob_uri="aimdware/C1/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, + 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() diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..50d3e5e --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,809 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aimdware-backend" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "pydantic-settings" }, + { name = "sqlmodel" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "fastapi", specifier = ">=0.115" }, + { 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 = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[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 = "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 = "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" }, +] From 0d48cc74c672e4e9f91efecacfdc4326a6c4632d Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 06:31:48 +0800 Subject: [PATCH 11/60] chore(e2e): real-backend smoke + relax ingest body optional fields --- backend/scripts/seed_for_e2e.py | 101 ++++++++++++++ backend/src/aimdware_backend/routes/ingest.py | 5 +- scripts/smoke_e2e.sh | 129 ++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/seed_for_e2e.py create mode 100755 scripts/smoke_e2e.sh diff --git a/backend/scripts/seed_for_e2e.py b/backend/scripts/seed_for_e2e.py new file mode 100644 index 0000000..4f6f607 --- /dev/null +++ b/backend/scripts/seed_for_e2e.py @@ -0,0 +1,101 @@ +"""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/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index 997baa9..f0f4165 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -34,7 +34,10 @@ class IngestContextBody(BaseModel): blob_hash: str # hex-encoded sha256 blob_uri: str blob_size: int = Field(ge=0) - model: str + # 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 diff --git a/scripts/smoke_e2e.sh b/scripts/smoke_e2e.sh new file mode 100755 index 0000000..350be84 --- /dev/null +++ b/scripts/smoke_e2e.sh @@ -0,0 +1,129 @@ +#!/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: ./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) +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 }); + } + 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 +sleep 3 + +# 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 From 661a85cacf608ccaa98d28ba53a10612571f071a Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 06:47:35 +0800 Subject: [PATCH 12/60] feat: admin payload-fetch endpoint + inspectable blob format --- backend/pyproject.toml | 1 + backend/src/aimdware_backend/admin_auth.py | 37 ++++ backend/src/aimdware_backend/jbox.py | 43 +++++ backend/src/aimdware_backend/main.py | 3 +- backend/src/aimdware_backend/routes/admin.py | 74 ++++++++ backend/src/aimdware_backend/settings.py | 10 + backend/tests/test_admin.py | 190 +++++++++++++++++++ backend/uv.lock | 2 + llm-client/src/capture.test.ts | 34 +++- llm-client/src/capture.ts | 34 +++- llm-client/src/handler.test.ts | 6 +- 11 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 backend/src/aimdware_backend/admin_auth.py create mode 100644 backend/src/aimdware_backend/jbox.py create mode 100644 backend/src/aimdware_backend/routes/admin.py create mode 100644 backend/tests/test_admin.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 66fef9c..f56c925 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "uvicorn[standard]>=0.32", "alembic>=1.13", "pydantic-settings>=2.5", + "httpx>=0.28.1", ] [dependency-groups] diff --git a/backend/src/aimdware_backend/admin_auth.py b/backend/src/aimdware_backend/admin_auth.py new file mode 100644 index 0000000..b5fe7ee --- /dev/null +++ b/backend/src/aimdware_backend/admin_auth.py @@ -0,0 +1,37 @@ +"""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/jbox.py b/backend/src/aimdware_backend/jbox.py new file mode 100644 index 0000000..786aff9 --- /dev/null +++ b/backend/src/aimdware_backend/jbox.py @@ -0,0 +1,43 @@ +"""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) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout_s + + 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) 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 + + return TboxWebDAVReader(settings.tbox_url) diff --git a/backend/src/aimdware_backend/main.py b/backend/src/aimdware_backend/main.py index fc1258c..9cecf0e 100644 --- a/backend/src/aimdware_backend/main.py +++ b/backend/src/aimdware_backend/main.py @@ -3,12 +3,13 @@ from fastapi import FastAPI -from aimdware_backend.routes import ingest +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 diff --git a/backend/src/aimdware_backend/routes/admin.py b/backend/src/aimdware_backend/routes/admin.py new file mode 100644 index 0000000..55672d9 --- /dev/null +++ b/backend/src/aimdware_backend/routes/admin.py @@ -0,0 +1,74 @@ +"""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 + +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() + + +@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. + + The backend itself stores no content, just the metadata + the + sha256 the router promised. This endpoint pulls the bytes from the + configured Tbox WebDAV endpoint, recomputes sha256, and returns + everything plus a `verified` flag so the caller knows whether the + blob has been tampered with since capture. + + The backend does not mutate ContextRecord.blob_status here; that + transition is reserved for the admin script's explicit verify + command. + """ + record = session.get(ContextRecord, record_id) + if record is None: + raise HTTPException(status_code=404, detail="record not found") + + 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() + verified = actual == record.blob_hash + + return { + "record_id": str(record.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"), + } diff --git a/backend/src/aimdware_backend/settings.py b/backend/src/aimdware_backend/settings.py index 3b5d6fb..7ffd937 100644 --- a/backend/src/aimdware_backend/settings.py +++ b/backend/src/aimdware_backend/settings.py @@ -9,5 +9,15 @@ class Settings(BaseSettings): 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" + settings = Settings() diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..877f403 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,190 @@ +"""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: + 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, + model="gpt-4o-mini", + router_version="0.0.0", + blob_uri="aimdware/ECE4721J/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 diff --git a/backend/uv.lock b/backend/uv.lock index 50d3e5e..c046a1e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -9,6 +9,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "fastapi" }, + { name = "httpx" }, { name = "pydantic-settings" }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, @@ -26,6 +27,7 @@ dev = [ 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" }, diff --git a/llm-client/src/capture.test.ts b/llm-client/src/capture.test.ts index 4ad753e..42f6511 100644 --- a/llm-client/src/capture.test.ts +++ b/llm-client/src/capture.test.ts @@ -26,7 +26,7 @@ function makeStreamingResponse(chunks: string[], opts: ResponseInit = {}) { }); } -test("captureChat: non-streaming response — clientResponse byte-exact + blob holds request + response", async () => { +test("captureChat: non-streaming response — clientResponse byte-exact + blob holds request + response as parsed objects", async () => { const requestText = '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'; const responseText = '{"id":"chatcmpl-xyz","choices":[{"message":{"content":"hello"}}]}'; @@ -41,14 +41,23 @@ test("captureChat: non-streaming response — clientResponse byte-exact + blob h const result = await captureP; const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.request_text).toBe(requestText); - expect(blob.response_text).toBe(responseText); + expect(blob.request).toEqual({ + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + }); + expect(blob.response).toEqual({ + id: "chatcmpl-xyz", + choices: [{ message: { content: "hello" } }], + }); expect(blob.upstream_status).toBe(200); expect(typeof blob.ts).toBe("string"); expect(result.record_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + // Blob is pretty-printed so it's readable in jbox / an editor. + expect(dec(result.blob_bytes)).toContain('\n "request":'); }); -test("captureChat: streaming response — clientResponse byte-for-byte + blob has concatenated SSE text", async () => { +test("captureChat: streaming response — clientResponse byte-for-byte + blob has raw SSE text", async () => { const chunks = [ 'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n', 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n', @@ -63,8 +72,14 @@ test("captureChat: streaming response — clientResponse byte-for-byte + blob ha const result = await captureP; const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.response_text).toBe(chunks.join("")); - expect(blob.request_text).toBe(requestText); + // SSE isn't JSON; response stays a string so the raw stream is preserved. + expect(blob.response).toBe(chunks.join("")); + // Request is always a JSON object. + expect(blob.request).toEqual({ + model: "gpt-4o", + stream: true, + messages: [], + }); }); test("captureChat: blob_hash equals sha256(blob_bytes)", async () => { @@ -77,14 +92,15 @@ test("captureChat: blob_hash equals sha256(blob_bytes)", async () => { expect(result.blob_size).toBe(result.blob_bytes.byteLength); }); -test("captureChat: 204 / empty body — captures empty response_text without hanging", async () => { +test("captureChat: 204 / empty body — captures empty response without hanging", async () => { const upstreamRes = new Response(null, { status: 204 }); const { clientResponse, captureP } = captureChat(enc("x"), upstreamRes); expect(clientResponse.status).toBe(204); const result = await captureP; const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.response_text).toBe(""); + // Empty body isn't valid JSON; response stays as the empty string. + expect(blob.response).toBe(""); expect(blob.upstream_status).toBe(204); }); @@ -101,5 +117,5 @@ test("captureChat: still captures full response even if client cancels mid-strea const result = await captureP; const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.response_text).toBe(chunks.join("")); + expect(blob.response).toBe(chunks.join("")); }); diff --git a/llm-client/src/capture.ts b/llm-client/src/capture.ts index 946246a..91849c6 100644 --- a/llm-client/src/capture.ts +++ b/llm-client/src/capture.ts @@ -72,19 +72,24 @@ function buildResult(args: { responseChunks: Uint8Array[]; upstream_status: number; }): CaptureResult { - const response_text = decodeBytes(concatChunks(args.responseChunks)); - const request_text = decodeBytes(args.requestBytes); - // Field order is deliberate: changing it changes the hash, so it's - // pinned via the literal here. JSON.stringify preserves insertion - // order for plain objects in V8/JSC/Bun. + const responseText = decodeBytes(concatChunks(args.responseChunks)); + const requestText = decodeBytes(args.requestBytes); + + // Parse JSON when possible so the on-disk blob is directly human-readable + // (no nested-JSON-string escaping). Streaming responses fall back to the + // raw SSE text. Request is always JSON for OpenAI Chat Completions, but + // we still tryParse defensively. const blob = { record_id: args.record_id, ts: args.ts.toISOString(), upstream_status: args.upstream_status, - request_text, - response_text, + request: tryParseJSON(requestText), + response: tryParseJSON(responseText), }; - const blob_bytes = new TextEncoder().encode(JSON.stringify(blob)); + + // Pretty-print so opening the file in jbox / a text editor reads cleanly. + // The hash binds to this exact byte representation (key order + indent). + 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; @@ -97,6 +102,19 @@ function buildResult(args: { }; } +/** + * Parse `s` as JSON if it is valid JSON; otherwise return the original + * string. Used so the captured blob holds structured request/response + * objects when possible (instead of opaque escaped strings). + */ +function tryParseJSON(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} + function decodeBytes(bytes: Uint8Array): string { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } diff --git a/llm-client/src/handler.test.ts b/llm-client/src/handler.test.ts index 757946c..d045416 100644 --- a/llm-client/src/handler.test.ts +++ b/llm-client/src/handler.test.ts @@ -105,8 +105,8 @@ test("POST /v1/chat/completions fires onCapture in background with full blob", a expect(captured).toBeDefined(); const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); - expect(blob.request_text).toBe('{"model":"gpt-4o","messages":[]}'); - expect(blob.response_text).toBe('{"id":"y","model":"gpt-4o"}'); + expect(blob.request).toEqual({ model: "gpt-4o", messages: [] }); + expect(blob.response).toEqual({ id: "y", model: "gpt-4o" }); expect(blob.upstream_status).toBe(200); }); @@ -151,5 +151,5 @@ test("/v1/chat/completions streams response while still capturing", async () => await Bun.sleep(10); expect(captured).toBeDefined(); const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); - expect(blob.response_text).toBe(chunks.join("")); + expect(blob.response).toBe(chunks.join("")); }); From dfd44c050f47ececbfc53a4675aa4f14c9cbe312 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 06:57:46 +0800 Subject: [PATCH 13/60] feat(llm-client): atomic claim + busy_timeout for multi-worker safety --- llm-client/src/ingest-worker.ts | 2 +- llm-client/src/queue.multiproc.test.ts | 114 +++++++++++++++++++++++++ llm-client/src/queue.test.ts | 76 +++++++++++++++++ llm-client/src/queue.ts | 63 ++++++++++++-- 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 llm-client/src/queue.multiproc.test.ts diff --git a/llm-client/src/ingest-worker.ts b/llm-client/src/ingest-worker.ts index 4a1963c..600e57b 100644 --- a/llm-client/src/ingest-worker.ts +++ b/llm-client/src/ingest-worker.ts @@ -72,7 +72,7 @@ export async function runOnce( const concurrency = opts.concurrency ?? 4; const backoff = opts.backoff ?? DEFAULT_BACKOFF; - const ready = opts.queue.pickReady(now, limit); + const ready = opts.queue.claim(now, limit); const summary: RunSummary = { processed: ready.length, diff --git a/llm-client/src/queue.multiproc.test.ts b/llm-client/src/queue.multiproc.test.ts new file mode 100644 index 0000000..c7654cf --- /dev/null +++ b/llm-client/src/queue.multiproc.test.ts @@ -0,0 +1,114 @@ +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"; + +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, + course_code: "ECE4721J", + 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/queue.test.ts b/llm-client/src/queue.test.ts index 4df7ce2..843780d 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/queue.test.ts @@ -141,3 +141,79 @@ test("pickReady returns records in any active state (captured / ingested / synce 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/queue.ts b/llm-client/src/queue.ts index 22a524a..e53c6aa 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -31,6 +31,7 @@ export type QueueStatus = { attempts: number; next_attempt_at: number; last_error: string | null; + claimed_at: number | null; }; export type ReadyRecord = { @@ -47,7 +48,8 @@ const SCHEMA_STATEMENTS = [ next_attempt_at INTEGER NOT NULL, last_error TEXT, created_at INTEGER NOT NULL, - cache_evicted INTEGER NOT NULL DEFAULT 0 + cache_evicted INTEGER NOT NULL DEFAULT 0, + claimed_at INTEGER )`, `CREATE INDEX IF NOT EXISTS ix_outbox_active ON outbox (state, next_attempt_at)`, @@ -61,6 +63,9 @@ export class IngestQueue { 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); } @@ -90,6 +95,49 @@ export class IngestQueue { })); } + /** + * 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( + `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. */ @@ -97,7 +145,8 @@ export class IngestQueue { this.db .prepare( `UPDATE outbox - SET state = ?, attempts = 0, next_attempt_at = ?, last_error = NULL + SET state = ?, attempts = 0, next_attempt_at = ?, + last_error = NULL, claimed_at = NULL WHERE record_id = ?`, ) .run(newState, nextAttemptAt, record_id); @@ -105,7 +154,8 @@ export class IngestQueue { /** * Retry the current stage. Increments attempts, sets next_attempt_at, - * records error. State unchanged. + * 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 @@ -113,7 +163,8 @@ export class IngestQueue { `UPDATE outbox SET attempts = attempts + 1, next_attempt_at = ?, - last_error = ? + last_error = ?, + claimed_at = NULL WHERE record_id = ?`, ) .run(nextAttemptAt, error, record_id); @@ -128,7 +179,7 @@ export class IngestQueue { this.db .prepare( `UPDATE outbox - SET state = ?, last_error = ? + SET state = ?, last_error = ?, claimed_at = NULL WHERE record_id = ?`, ) .run(finalState, error, record_id); @@ -137,7 +188,7 @@ export class IngestQueue { statusOf(record_id: string): QueueStatus | undefined { const row = this.db .prepare( - `SELECT record_id, state, attempts, next_attempt_at, last_error + `SELECT record_id, state, attempts, next_attempt_at, last_error, claimed_at FROM outbox WHERE record_id = ?`, ) .get(record_id) as QueueStatus | null; From 8ba5fef3db46b34b742f146a12e5e7261b1a635e Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 07:12:24 +0800 Subject: [PATCH 14/60] feat: integrate real Tbox WebDAV (basic-auth + auto-MKCOL) e2e --- backend/src/aimdware_backend/jbox.py | 17 ++- backend/src/aimdware_backend/settings.py | 2 + backend/tests/test_jbox_real.py | 78 ++++++++++ llm-client/src/config.ts | 2 + llm-client/src/main.ts | 7 +- llm-client/src/sync.real.test.ts | 90 ++++++++++++ llm-client/src/sync.ts | 21 ++- scripts/smoke_e2e_real_tbox.sh | 177 +++++++++++++++++++++++ 8 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_jbox_real.py create mode 100644 llm-client/src/sync.real.test.ts create mode 100755 scripts/smoke_e2e_real_tbox.sh diff --git a/backend/src/aimdware_backend/jbox.py b/backend/src/aimdware_backend/jbox.py index 786aff9..8ed4963 100644 --- a/backend/src/aimdware_backend/jbox.py +++ b/backend/src/aimdware_backend/jbox.py @@ -19,16 +19,22 @@ class JboxNotFound(Exception): class TboxWebDAVReader: """Fetch blobs from a Tbox WebDAV endpoint (default: backend-side Tbox).""" - def __init__(self, base_url: str, timeout_s: float = 10.0) -> None: + 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) as client: + 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}") @@ -40,4 +46,9 @@ def default_reader() -> JboxReader: """Build the default reader from settings.""" from aimdware_backend.settings import settings - return TboxWebDAVReader(settings.tbox_url) + 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/settings.py b/backend/src/aimdware_backend/settings.py index 7ffd937..ad65cb9 100644 --- a/backend/src/aimdware_backend/settings.py +++ b/backend/src/aimdware_backend/settings.py @@ -18,6 +18,8 @@ class Settings(BaseSettings): # 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/test_jbox_real.py b/backend/tests/test_jbox_real.py new file mode 100644 index 0000000..e9530e6 --- /dev/null +++ b/backend/tests/test_jbox_real.py @@ -0,0 +1,78 @@ +"""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 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). + try: + httpx.delete(f"{TBOX_URL}/{subdir}", auth=auth) + except httpx.HTTPError: + pass + + +@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/llm-client/src/config.ts b/llm-client/src/config.ts index b4d4cd2..9888b88 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -16,6 +16,8 @@ const RawConfigSchema = z.object({ 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 & { diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 5b99114..4f436c5 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -126,7 +126,12 @@ async function main() { const queueDb = join(cacheDir, "queue.db"); const queue = new IngestQueue(queueDb); - const webdavPut = makeWebDAVPut(config.tbox_url); + const webdavPut = makeWebDAVPut( + config.tbox_url, + config.tbox_user + ? { username: config.tbox_user, password: config.tbox_pass } + : undefined, + ); const handler = createHandler({ upstream: { diff --git a/llm-client/src/sync.real.test.ts b/llm-client/src/sync.real.test.ts new file mode 100644 index 0000000..e8ea344 --- /dev/null +++ b/llm-client/src/sync.real.test.ts @@ -0,0 +1,90 @@ +/** + * 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/sync.ts b/llm-client/src/sync.ts index 0f8a867..f00c44f 100644 --- a/llm-client/src/sync.ts +++ b/llm-client/src/sync.ts @@ -34,13 +34,28 @@ export async function syncBlob( } } +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): WebDAVPutLike { - const client: WebDAVClient = createClient(tboxUrl); +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) => { - // webdav expects Buffer | string | Readable. Convert Uint8Array. + 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/scripts/smoke_e2e_real_tbox.sh b/scripts/smoke_e2e_real_tbox.sh new file mode 100755 index 0000000..713faf3 --- /dev/null +++ b/scripts/smoke_e2e_real_tbox.sh @@ -0,0 +1,177 @@ +#!/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: ./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 + # best-effort: clean the Tbox subdir we created + curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE "$TBOX_URL/$TBOX_SUBDIR" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +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 ---" From 572c53159ef96c3a41115058fd5eb03975b0ba3c Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 07:14:10 +0800 Subject: [PATCH 15/60] feat(llm-client): multi-target compile + example config --- llm-client/aimdware.example.yaml | 47 ++++++++++++++++++++++++++++++++ llm-client/package.json | 10 +++++-- llm-client/src/main.ts | 12 ++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 llm-client/aimdware.example.yaml diff --git a/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml new file mode 100644 index 0000000..d6eb7f3 --- /dev/null +++ b/llm-client/aimdware.example.yaml @@ -0,0 +1,47 @@ +# 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 + +# 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 (any OpenAI-compatible API works — OpenAI, +# Kimi, GLM, DeepSeek, Qwen, chatanywhere, …). +upstream: + type: openai + base_url: https://api.openai.com + api_key: sk-REPLACE_ME + +# ---- 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/package.json b/llm-client/package.json index 9e40638..02da65f 100644 --- a/llm-client/package.json +++ b/llm-client/package.json @@ -1,6 +1,6 @@ { "name": "aimdware-llm-client", - "version": "0.0.0", + "version": "0.1.0", "private": true, "type": "module", "module": "src/main.ts", @@ -8,7 +8,13 @@ "test": "bun test", "typecheck": "tsc --noEmit", "dev": "bun run src/main.ts", - "build": "bun build --compile --minify --sourcemap src/main.ts --outfile dist/aimdware-router" + "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", diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 4f436c5..e19777b 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -13,8 +13,9 @@ import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; import { writeAtomic, bytesToHex, redactToken } from "./util"; import { startEvictionLoop } from "./eviction"; import type { CaptureResult } from "./capture"; +import pkg from "../package.json" with { type: "json" }; -const ROUTER_VERSION = "0.0.0"; +const ROUTER_VERSION = pkg.version; function expandHome(p: string): string { return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; @@ -106,7 +107,14 @@ async function main() { }); if (values.help) { - console.log("Usage: aimdware-router [--config ]"); + console.log(`aimdware-router ${ROUTER_VERSION} + +Usage: + aimdware-router --config start the router + 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; } From ac8a6d3b87118e8b8a7fa5f639512c5aa101f015 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 07:17:34 +0800 Subject: [PATCH 16/60] feat(backend): TT-side admin CLI (aimdware-admin) --- backend/pyproject.toml | 3 + backend/src/aimdware_backend/admin_cli.py | 402 ++++++++++++++++++++++ backend/tests/test_admin_cli.py | 113 ++++++ wiki/admin-script.md | 121 ++++--- 4 files changed, 584 insertions(+), 55 deletions(-) create mode 100644 backend/src/aimdware_backend/admin_cli.py create mode 100644 backend/tests/test_admin_cli.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f56c925..a1cceda 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "httpx>=0.28.1", ] +[project.scripts] +aimdware-admin = "aimdware_backend.admin_cli:main" + [dependency-groups] dev = [ "pytest>=8.3", diff --git a/backend/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py new file mode 100644 index 0000000..1296628 --- /dev/null +++ b/backend/src/aimdware_backend/admin_cli.py @@ -0,0 +1,402 @@ +"""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 sqlmodel import Session, SQLModel, select + +from aimdware_backend.db import get_engine +from aimdware_backend.models import ( + BlobStatus, + ContextRecord, + Course, + Enrollment, + Role, + StudentToken, + User, + utcnow, +) + + +# --- importable command functions (also covered by tests) --------------- + + +def user_create( + session: Session, *, jaccount: str, email: str, display_name: str +) -> User: + """Create a new User row and return it.""" + user = User(jaccount=jaccount, email=email, display_name=display_name) + 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) + + +# --- output helpers ----------------------------------------------------- + + +def _print_json(obj: Any) -> None: + print(json.dumps(obj, default=str, indent=2)) + + +# --- CLI command dispatchers -------------------------------------------- + + +def _cmd_user_create(session: Session, args: argparse.Namespace) -> None: + u = user_create( + session, + jaccount=args.jaccount, + email=args.email, + display_name=args.name, + ) + _print_json({"id": str(u.id), "jaccount": u.jaccount, "email": u.email}) + + +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) -> None: + e = enroll( + session, + jaccount=args.user, + course_code=args.course, + role=Role(args.role), + ) + _print_json( + { + "user_id": str(e.user_id), + "course_id": str(e.course_id), + "role": e.role, + } + ) + + +def _cmd_token_issue(session: Session, args: argparse.Namespace) -> None: + 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)} + ) + + +def _cmd_token_revoke(session: Session, args: argparse.Namespace) -> None: + n = token_revoke(session, prefix=args.prefix) + _print_json({"revoked": n}) + + +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: + 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.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), + "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", required=True) + p_uc.add_argument("--email", required=True) + p_uc.add_argument("--name", required=True) + 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", required=True, help="user jaccount") + p_enr.add_argument("--course", required=True, help="course code") + p_enr.add_argument("--role", default="student", choices=["student", "admin"]) + 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", required=True, help="user jaccount") + p_ti.set_defaults(func=_cmd_token_issue) + p_tr = s_tok.add_parser("revoke") + p_tr.add_argument( + "--prefix", required=True, help="8-char prefix shown when issued" + ) + 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( + "--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() + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + if inspect.iscoroutinefunction(args.func): + asyncio.run(args.func(session, args)) + else: + args.func(session, args) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py new file mode 100644 index 0000000..1075d14 --- /dev/null +++ b/backend/tests/test_admin_cli.py @@ -0,0 +1,113 @@ +"""Tests for the TT admin CLI's importable command functions.""" +from __future__ import annotations + +import hashlib + +from sqlmodel import Session, select + +from aimdware_backend.admin_cli import ( + course_create, + enroll, + token_issue, + token_revoke, + user_create, + user_get, +) +from aimdware_backend.models import Course, Enrollment, Role, StudentToken, User + + +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 diff --git a/wiki/admin-script.md b/wiki/admin-script.md index 51a59a7..286e02c 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -1,83 +1,94 @@ # 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 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 --user zhangsan # one token per student; not course-scoped -aimdware-admin token revoke --user zhangsan -aimdware-admin token rotate --user zhangsan # atomic revoke + issue -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] [--status pending|uploaded|...] [--limit N] +aimdware-admin record payload --id # fetch blob from Tbox + verify sha256 ``` -- `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. ## Token lifecycle -The plaintext token only exists at issue time. Backend stores -`sha256(plaintext)`; the student's router config is the only place the -plaintext lives long-term. +Backend stores `sha256(plaintext)`; the student's router config is the +only place the plaintext lives long-term. ``` issue: - plaintext = "st_" + crypto.randomBytes(32).base64url + plaintext = "st_" + secrets.token_urlsafe(32) hash = sha256(plaintext) - prefix = plaintext[:8] # human ID, e.g. "st_K9aB" + prefix = plaintext[:8] # human ID, e.g. "st_K9aB6r" - with atomic transaction: - revoke any existing active token for this user + 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 - # ↳ hand to student through any channel + print(plaintext) # ONLY time plaintext is observable revoke: UPDATE student_tokens SET revoked_at = NOW() - WHERE user_id = uid AND revoked_at IS NULL + WHERE prefix = ? AND revoked_at IS NULL -rotate: - atomic { revoke; issue } - print new plaintext; student updates router config +rotate (= issue again): + atomic { revoke active; insert new } + print new plaintext ``` -`token issue` and `token rotate` print the plaintext exactly once and -nowhere else. If the student loses it, only `rotate` recovers (we -cannot read the old one back). +If the student loses the plaintext, `token issue` again is the only +path — we cannot recover the old one. -## Project layout +## record payload -``` -src/aimdware_admin/ - cli.py - jbox_inspect.py -``` +`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 `) +- `enroll bulk --csv roster.csv` +- `record verify` that updates `blob_status` after fetching +- Audit log of who ran which command when From 302b5cfd1e636fb5cafc531ae35345756d6ed9a3 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 07:50:58 +0800 Subject: [PATCH 17/60] feat: session-keyed blobs (O(N) storage for agents) --- backend/src/aimdware_backend/models.py | 5 + backend/src/aimdware_backend/routes/admin.py | 98 ++++++++--- backend/src/aimdware_backend/routes/ingest.py | 4 + backend/tests/test_admin.py | 91 ++++++++++ backend/tests/test_ingest.py | 2 + backend/tests/test_models.py | 4 + llm-client/src/capture.test.ts | 83 +++------ llm-client/src/capture.ts | 82 +++------ llm-client/src/eviction.test.ts | 2 + llm-client/src/handler.test.ts | 11 +- llm-client/src/ingest-worker.test.ts | 2 + llm-client/src/ingest.test.ts | 2 + llm-client/src/ingest.ts | 2 + llm-client/src/main.ts | 68 +++++--- llm-client/src/queue.multiproc.test.ts | 2 + llm-client/src/queue.test.ts | 2 + llm-client/src/session-blob.test.ts | 126 ++++++++++++++ llm-client/src/session-blob.ts | 63 +++++++ llm-client/src/session.test.ts | 138 +++++++++++++++ llm-client/src/session.ts | 108 ++++++++++++ scripts/smoke_e2e_agent.sh | 164 ++++++++++++++++++ scripts/smoke_e2e_real_tbox.sh | 10 +- 22 files changed, 903 insertions(+), 166 deletions(-) create mode 100644 llm-client/src/session-blob.test.ts create mode 100644 llm-client/src/session-blob.ts create mode 100644 llm-client/src/session.test.ts create mode 100644 llm-client/src/session.ts create mode 100755 scripts/smoke_e2e_agent.sh diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py index 7f806f2..e4be09a 100644 --- a/backend/src/aimdware_backend/models.py +++ b/backend/src/aimdware_backend/models.py @@ -80,6 +80,11 @@ class ContextRecord(SQLModel, table=True): 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) + # 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 diff --git a/backend/src/aimdware_backend/routes/admin.py b/backend/src/aimdware_backend/routes/admin.py index 55672d9..b41d6a1 100644 --- a/backend/src/aimdware_backend/routes/admin.py +++ b/backend/src/aimdware_backend/routes/admin.py @@ -6,7 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import Session +from sqlmodel import Session, select from aimdware_backend.admin_auth import authenticate_admin from aimdware_backend.db import get_session @@ -26,28 +26,19 @@ def get_jbox_reader() -> JboxReader: return default_reader() -@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. +def _latest_record_for_session( + session: Session, session_id: UUID +) -> ContextRecord | None: + return session.exec( + select(ContextRecord) + .where(ContextRecord.session_id == session_id) + .order_by(ContextRecord.turn_count.desc()) # type: ignore[union-attr] + ).first() - The backend itself stores no content, just the metadata + the - sha256 the router promised. This endpoint pulls the bytes from the - configured Tbox WebDAV endpoint, recomputes sha256, and returns - everything plus a `verified` flag so the caller knows whether the - blob has been tampered with since capture. - - The backend does not mutate ContextRecord.blob_status here; that - transition is reserved for the admin script's explicit verify - command. - """ - record = session.get(ContextRecord, record_id) - if record is None: - raise HTTPException(status_code=404, detail="record not found") +async def _fetch_and_verify( + record: ContextRecord, reader: JboxReader +) -> tuple[bytes, bool]: try: bytes_ = await reader.get(record.blob_uri) except JboxNotFound: @@ -58,17 +49,76 @@ async def get_payload( raise HTTPException( status_code=502, detail=f"jbox fetch failed: {exc}" ) from None - actual = hashlib.sha256(bytes_).digest() - verified = actual == record.blob_hash + 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(), + "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 index f0f4165..a557a05 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -30,6 +30,8 @@ class IngestContextBody(BaseModel): model_config = ConfigDict(extra="forbid") record_id: UUID + session_id: UUID + turn_count: int = Field(ge=1, default=1) course_code: str blob_hash: str # hex-encoded sha256 blob_uri: str @@ -106,6 +108,8 @@ def post_context( id=body.record_id, user_id=user.id, course_id=course.id, + session_id=body.session_id, + turn_count=body.turn_count, ts=body.ts, model=body.model, prompt_tokens=body.prompt_tokens, diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 877f403..fdf098b 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -36,6 +36,8 @@ def override_session(): 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]) @@ -43,6 +45,8 @@ def _seed_record(session: Session, payload: bytes) -> ContextRecord: rec = ContextRecord( user_id=user.id, course_id=course.id, + session_id=uuid4(), + turn_count=1, model="gpt-4o-mini", router_version="0.0.0", blob_uri="aimdware/ECE4721J/sample.json", @@ -188,3 +192,90 @@ def test_payload_endpoint_does_not_mutate_status( 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, + session_id=sess_id, + turn_count=i, + model="gpt-4o-mini", + router_version="0.0.0", + blob_uri=f"aimdware/ECE4721J/{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_ingest.py b/backend/tests/test_ingest.py index 064b2f1..51199ec 100644 --- a/backend/tests/test_ingest.py +++ b/backend/tests/test_ingest.py @@ -50,6 +50,8 @@ def enrolled_student(session: Session) -> tuple[str, User, Course]: def _body(course_code: str = "ECE4721J", **overrides) -> dict: base = { "record_id": str(uuid4()), + "session_id": str(uuid4()), + "turn_count": 1, "course_code": course_code, "blob_hash": "ab" * 32, "blob_uri": "aimdware/ECE4721J/abc.json", diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 03bf09e..456c754 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -100,6 +100,8 @@ def test_context_record_persists_with_blob_metadata(session: Session) -> None: rec = ContextRecord( user_id=user.id, course_id=course.id, + session_id=uuid4(), + turn_count=1, model="gpt-4o-mini", prompt_tokens=10, completion_tokens=20, @@ -129,6 +131,8 @@ def test_context_record_id_is_pk_unique(session: Session) -> None: base = dict( user_id=user.id, course_id=course.id, + session_id=uuid4(), + turn_count=1, model="gpt", router_version="0.0.0", blob_uri="x.json", diff --git a/llm-client/src/capture.test.ts b/llm-client/src/capture.test.ts index 42f6511..61d9956 100644 --- a/llm-client/src/capture.test.ts +++ b/llm-client/src/capture.test.ts @@ -1,10 +1,9 @@ import { test, expect } from "bun:test"; -import { captureChat } from "./capture"; +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); } @@ -26,82 +25,48 @@ function makeStreamingResponse(chunks: string[], opts: ResponseInit = {}) { }); } -test("captureChat: non-streaming response — clientResponse byte-exact + blob holds request + response as parsed objects", async () => { +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":"chatcmpl-xyz","choices":[{"message":{"content":"hello"}}]}'; + 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 result = await captureP; - const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.request).toEqual({ - model: "gpt-4o", - messages: [{ role: "user", content: "hi" }], - }); - expect(blob.response).toEqual({ - id: "chatcmpl-xyz", - choices: [{ message: { content: "hello" } }], - }); - expect(blob.upstream_status).toBe(200); - expect(typeof blob.ts).toBe("string"); - expect(result.record_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - - // Blob is pretty-printed so it's readable in jbox / an editor. - expect(dec(result.blob_bytes)).toContain('\n "request":'); + 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 response — clientResponse byte-for-byte + blob has raw SSE text", async () => { +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 requestText = '{"model":"gpt-4o","stream":true,"messages":[]}'; - const upstreamRes = makeStreamingResponse(chunks); - const { clientResponse, captureP } = captureChat(enc(requestText), upstreamRes); + const { clientResponse, captureP } = captureChat(enc("req"), upstreamRes); expect(await clientResponse.text()).toBe(chunks.join("")); - const result = await captureP; - const blob = JSON.parse(dec(result.blob_bytes)); - // SSE isn't JSON; response stays a string so the raw stream is preserved. - expect(blob.response).toBe(chunks.join("")); - // Request is always a JSON object. - expect(blob.request).toEqual({ - model: "gpt-4o", - stream: true, - messages: [], - }); + const r = await captureP; + expect(dec(r.response_bytes)).toBe(chunks.join("")); }); -test("captureChat: blob_hash equals sha256(blob_bytes)", async () => { - const upstreamRes = new Response("response", { status: 200 }); - const { captureP } = captureChat(enc("request"), upstreamRes); - const result = await captureP; - - const expected = new Bun.CryptoHasher("sha256").update(result.blob_bytes).digest(); - expect(Buffer.from(result.blob_hash)).toEqual(Buffer.from(expected as Uint8Array)); - expect(result.blob_size).toBe(result.blob_bytes.byteLength); -}); - -test("captureChat: 204 / empty body — captures empty response without hanging", async () => { +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 result = await captureP; - const blob = JSON.parse(dec(result.blob_bytes)); - // Empty body isn't valid JSON; response stays as the empty string. - expect(blob.response).toBe(""); - expect(blob.upstream_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 () => { @@ -109,13 +74,19 @@ test("captureChat: still captures full response even if client cancels mid-strea const upstreamRes = makeStreamingResponse(chunks); const { clientResponse, captureP } = captureChat(enc("req"), upstreamRes); - - // Read the first chunk then cancel const reader = clientResponse.body!.getReader(); await reader.read(); await reader.cancel("client gone"); - const result = await captureP; - const blob = JSON.parse(dec(result.blob_bytes)); - expect(blob.response).toBe(chunks.join("")); + 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/capture.ts b/llm-client/src/capture.ts index 91849c6..940f96e 100644 --- a/llm-client/src/capture.ts +++ b/llm-client/src/capture.ts @@ -1,9 +1,16 @@ +/** + * 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; - blob_bytes: Uint8Array; - blob_hash: Uint8Array; - blob_size: number; + upstream_status: number; + request_bytes: Uint8Array; + response_bytes: Uint8Array; }; export function captureChat( @@ -15,15 +22,13 @@ export function captureChat( const upstream_status = upstreamRes.status; if (!upstreamRes.body) { - const captureP = Promise.resolve( - buildResult({ - record_id, - ts, - requestBytes, - responseChunks: [], - upstream_status, - }), - ); + 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, @@ -53,61 +58,24 @@ export function captureChat( } finally { reader.releaseLock(); } - return buildResult({ + return { record_id, ts, - requestBytes, - responseChunks: chunks, upstream_status, - }); + request_bytes: requestBytes, + response_bytes: concatChunks(chunks), + }; })(); return { clientResponse, captureP }; } -function buildResult(args: { - record_id: string; - ts: Date; - requestBytes: Uint8Array; - responseChunks: Uint8Array[]; - upstream_status: number; -}): CaptureResult { - const responseText = decodeBytes(concatChunks(args.responseChunks)); - const requestText = decodeBytes(args.requestBytes); - - // Parse JSON when possible so the on-disk blob is directly human-readable - // (no nested-JSON-string escaping). Streaming responses fall back to the - // raw SSE text. Request is always JSON for OpenAI Chat Completions, but - // we still tryParse defensively. - const blob = { - record_id: args.record_id, - ts: args.ts.toISOString(), - upstream_status: args.upstream_status, - request: tryParseJSON(requestText), - response: tryParseJSON(responseText), - }; - - // Pretty-print so opening the file in jbox / a text editor reads cleanly. - // The hash binds to this exact byte representation (key order + indent). - 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 { - record_id: args.record_id, - ts: args.ts, - blob_bytes, - blob_hash, - blob_size: blob_bytes.byteLength, - }; -} - /** * Parse `s` as JSON if it is valid JSON; otherwise return the original - * string. Used so the captured blob holds structured request/response - * objects when possible (instead of opaque escaped strings). + * string. Used so the session blob holds structured request/response + * objects when possible instead of opaque escaped strings. */ -function tryParseJSON(s: string): unknown { +export function tryParseJSON(s: string): unknown { try { return JSON.parse(s); } catch { @@ -115,7 +83,7 @@ function tryParseJSON(s: string): unknown { } } -function decodeBytes(bytes: Uint8Array): string { +export function decodeBytes(bytes: Uint8Array): string { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } diff --git a/llm-client/src/eviction.test.ts b/llm-client/src/eviction.test.ts index 9c3896a..92ce8f3 100644 --- a/llm-client/src/eviction.test.ts +++ b/llm-client/src/eviction.test.ts @@ -20,6 +20,8 @@ function fresh() { function body(id: string): IngestBody { return { record_id: id, + session_id: `sess-${id}`, + turn_count: 1, course_code: "ECE4721J", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, diff --git a/llm-client/src/handler.test.ts b/llm-client/src/handler.test.ts index d045416..ae6b0b8 100644 --- a/llm-client/src/handler.test.ts +++ b/llm-client/src/handler.test.ts @@ -104,10 +104,10 @@ test("POST /v1/chat/completions fires onCapture in background with full blob", a await captureDone; expect(captured).toBeDefined(); - const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); - expect(blob.request).toEqual({ model: "gpt-4o", messages: [] }); - expect(blob.response).toEqual({ id: "y", model: "gpt-4o" }); - expect(blob.upstream_status).toBe(200); + 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("/v1/chat/completions streams response while still capturing", async () => { @@ -150,6 +150,5 @@ test("/v1/chat/completions streams response while still capturing", async () => // capture happens after the stream completes — give the microtask a beat await Bun.sleep(10); expect(captured).toBeDefined(); - const blob = JSON.parse(new TextDecoder().decode(captured!.blob_bytes)); - expect(blob.response).toBe(chunks.join("")); + expect(new TextDecoder().decode(captured!.response_bytes)).toBe(chunks.join("")); }); diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/ingest-worker.test.ts index 9041cde..885472e 100644 --- a/llm-client/src/ingest-worker.test.ts +++ b/llm-client/src/ingest-worker.test.ts @@ -21,6 +21,8 @@ function freshQueue() { function body(id: string): IngestBody { return { record_id: id, + session_id: `sess-${id}`, + turn_count: 1, course_code: "ECE4721J", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, diff --git a/llm-client/src/ingest.test.ts b/llm-client/src/ingest.test.ts index 9422719..8b2fbed 100644 --- a/llm-client/src/ingest.test.ts +++ b/llm-client/src/ingest.test.ts @@ -33,6 +33,8 @@ afterEach(async () => { function sampleBody(): IngestBody { return { record_id: "11111111-1111-1111-1111-111111111111", + session_id: "22222222-2222-2222-2222-222222222222", + turn_count: 1, course_code: "ECE4721J", blob_hash: "deadbeef", blob_uri: "jbox://zhangsan/aimdware/ECE4721J/x.json", diff --git a/llm-client/src/ingest.ts b/llm-client/src/ingest.ts index 38ab5e2..7ceeb14 100644 --- a/llm-client/src/ingest.ts +++ b/llm-client/src/ingest.ts @@ -3,6 +3,8 @@ import type { FetchLike } from "./proxy"; export type IngestBody = { record_id: string; + session_id: string; + turn_count: number; course_code: string; blob_hash: string; blob_uri: string; diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index e19777b..cd7fe60 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -12,7 +12,9 @@ import { postContext, confirmUploaded, type IngestBody } from "./ingest"; import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; import { writeAtomic, bytesToHex, redactToken } from "./util"; import { startEvictionLoop } from "./eviction"; -import type { CaptureResult } from "./capture"; +import { tryParseJSON, decodeBytes } from "./capture"; +import { SessionTracker, type Message } from "./session"; +import { buildSessionBlob } from "./session-blob"; import pkg from "../package.json" with { type: "json" }; const ROUTER_VERSION = pkg.version; @@ -21,17 +23,13 @@ function expandHome(p: string): string { return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; } -function buildIngestBody(config: Config, r: CaptureResult): IngestBody { - return { - record_id: r.record_id, - course_code: config.course, - blob_hash: bytesToHex(r.blob_hash), - blob_uri: `${config.jbox_remote_path}/${r.record_id}.json`, - blob_size: r.blob_size, - ts: r.ts.toISOString(), - router_version: ROUTER_VERSION, - client_meta: { upstream_type: config.upstream.type }, - }; +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[]; + } + return []; } function buildStages( @@ -55,7 +53,9 @@ function buildStages( }; const sync: StageHandler = async (body) => { - const path = join(cacheDir, "records", `${body.record_id}.json`); + // Session-keyed cache file. May have been overwritten by a later turn + // of the same session — that's fine; we'll upload the latest state. + const path = join(cacheDir, "records", `${body.session_id}.json`); let data: Uint8Array; try { data = new Uint8Array(await Bun.file(path).arrayBuffer()); @@ -134,6 +134,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const queueDb = join(cacheDir, "queue.db"); const queue = new IngestQueue(queueDb); + const sessionTracker = new SessionTracker(); const webdavPut = makeWebDAVPut( config.tbox_url, config.tbox_user @@ -147,21 +148,48 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); api_key: config.upstream.api_key, }, onCapture: async (result) => { - const blobPath = join(recordsDir, `${result.record_id}.json`); + const messages = extractMessages(result.request_bytes); + const cls = sessionTracker.classify(messages, result.ts); + + const blob = buildSessionBlob({ + session_id: cls.session_id, + course: config.course, + 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 = join(recordsDir, `${cls.session_id}.json`); try { - await writeAtomic(blobPath, result.blob_bytes); + await writeAtomic(blobPath, blob.blob_bytes); } catch (e) { console.error( - `cache write failed for ${result.record_id}:`, + `cache write failed for record=${result.record_id} session=${cls.session_id}:`, (e as Error).message, ); - return; // don't enqueue if we can't preserve the blob + return; } - const body = buildIngestBody(config, result); + + const body: IngestBody = { + record_id: result.record_id, + session_id: cls.session_id, + turn_count: cls.turn_count, + course_code: config.course, + 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(result.blob_hash).slice(0, 16); + const hex = bytesToHex(blob.blob_hash).slice(0, 16); console.log( - `captured record=${result.record_id} hash=${hex}… size=${result.blob_size} -> queued`, + `captured record=${result.record_id} session=${cls.session_id} turn=${cls.turn_count} hash=${hex}… size=${blob.blob_size} -> queued`, ); }, }); diff --git a/llm-client/src/queue.multiproc.test.ts b/llm-client/src/queue.multiproc.test.ts index c7654cf..8ca313c 100644 --- a/llm-client/src/queue.multiproc.test.ts +++ b/llm-client/src/queue.multiproc.test.ts @@ -14,6 +14,8 @@ afterEach(() => { function body(id: string): IngestBody { return { record_id: id, + session_id: `sess-${id}`, + turn_count: 1, course_code: "ECE4721J", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, diff --git a/llm-client/src/queue.test.ts b/llm-client/src/queue.test.ts index 843780d..6062ff5 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/queue.test.ts @@ -17,6 +17,8 @@ function freshDb(): { path: string; q: IngestQueue } { function body(id: string): IngestBody { return { record_id: id, + session_id: `sess-${id}`, + turn_count: 1, course_code: "ECE4721J", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, diff --git a/llm-client/src/session-blob.test.ts b/llm-client/src/session-blob.test.ts new file mode 100644 index 0000000..6db82a1 --- /dev/null +++ b/llm-client/src/session-blob.test.ts @@ -0,0 +1,126 @@ +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("builds a session-keyed pretty-printed JSON blob with the final turn's messages + response", () => { + const r = buildSessionBlob({ + session_id: "11111111-2222-3333-4444-555555555555", + course: "ECE4721J", + 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)); + 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" }); + expect(obj.model).toBe("gpt-4o"); + expect(obj.messages).toHaveLength(3); + expect(obj.messages[2]).toEqual({ role: "user", content: "more" }); + expect(obj.latest_response.choices[0].message.content).toBe("sure"); +}); + +test("blob is pretty-printed and the hash matches sha256(blob_bytes)", () => { + const r = buildSessionBlob({ + session_id: "abc", + course: "X", + 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 in `latest_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", + 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.latest_response).toBe("string"); + expect(obj.latest_response).toBe(sse); +}); + +test("unparseable request bytes still produce a blob (messages=[], model=null)", () => { + const r = buildSessionBlob({ + session_id: "x", + course: "Y", + 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)); + expect(obj.messages).toEqual([]); + expect(obj.model).toBeNull(); +}); + +test("two consecutive turns of the same session produce strictly growing blob sizes", () => { + const turn1 = buildSessionBlob({ + session_id: "s1", + course: "X", + 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", + 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/session-blob.ts b/llm-client/src/session-blob.ts new file mode 100644 index 0000000..0b30457 --- /dev/null +++ b/llm-client/src/session-blob.ts @@ -0,0 +1,63 @@ +import { decodeBytes, tryParseJSON } from "./capture"; + +export type SessionBlobInput = { + session_id: string; + course: 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: + * - `messages` is the final turn's request.messages (already contains + * the full history because the agent re-sends everything every turn) + * - `latest_response` is the final turn's response + * + * TT reads this file and the conversation it sees is the conversation + * as of the latest turn — no per-turn snapshots to stitch together. + */ +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); + + let model: string | null = null; + let messages: unknown[] = []; + if (parsedReq && typeof parsedReq === "object") { + const req = parsedReq as { model?: unknown; messages?: unknown }; + model = typeof req.model === "string" ? req.model : null; + messages = Array.isArray(req.messages) ? req.messages : []; + } + + const blob = { + session_id: input.session_id, + course: input.course, + 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, + model, + messages, + latest_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/session.test.ts b/llm-client/src/session.test.ts new file mode 100644 index 0000000..34302f0 --- /dev/null +++ b/llm-client/src/session.test.ts @@ -0,0 +1,138 @@ +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("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/session.ts b/llm-client/src/session.ts new file mode 100644 index 0000000..944e0bc --- /dev/null +++ b/llm-client/src/session.ts @@ -0,0 +1,108 @@ +/** + * 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 { + // JSON.stringify with stable key ordering would be safer; in practice + // OpenAI-compatible payloads have stable shapes from the client, so a + // naive stringify match is sufficient and avoids a deep-compare dep. + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/scripts/smoke_e2e_agent.sh b/scripts/smoke_e2e_agent.sh new file mode 100755 index 0000000..8b8d8fe --- /dev/null +++ b/scripts/smoke_e2e_agent.sh @@ -0,0 +1,164 @@ +#!/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}" +TBOX_SUBDIR="aimdware/$COURSE" +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_SUBDIR on Tbox" + 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 " 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_SUBDIR/" \ + | grep -oE '[^<]+\.json' | wc -l | tr -d ' ') +echo " json files on jbox under /$TBOX_SUBDIR/: $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['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/scripts/smoke_e2e_real_tbox.sh b/scripts/smoke_e2e_real_tbox.sh index 713faf3..3e78804 100755 --- a/scripts/smoke_e2e_real_tbox.sh +++ b/scripts/smoke_e2e_real_tbox.sh @@ -41,9 +41,13 @@ UPSTREAM_PORT="$((20000 + RANDOM % 30000))" cleanup() { echo "--- cleanup ---" jobs -p | xargs -r kill 2>/dev/null || true - # best-effort: clean the Tbox subdir we created - curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE "$TBOX_URL/$TBOX_SUBDIR" >/dev/null 2>&1 || true - rm -rf "$WORK" + 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 From 4bb1aefe7c009d30062181c831039c4844eec792 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 07:58:29 +0800 Subject: [PATCH 18/60] chore: rename scripts -> test-scripts --- {scripts => test-scripts}/smoke_e2e.sh | 2 +- {scripts => test-scripts}/smoke_e2e_agent.sh | 0 {scripts => test-scripts}/smoke_e2e_real_tbox.sh | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename {scripts => test-scripts}/smoke_e2e.sh (98%) rename {scripts => test-scripts}/smoke_e2e_agent.sh (100%) rename {scripts => test-scripts}/smoke_e2e_real_tbox.sh (98%) diff --git a/scripts/smoke_e2e.sh b/test-scripts/smoke_e2e.sh similarity index 98% rename from scripts/smoke_e2e.sh rename to test-scripts/smoke_e2e.sh index 350be84..095cf61 100755 --- a/scripts/smoke_e2e.sh +++ b/test-scripts/smoke_e2e.sh @@ -3,7 +3,7 @@ # Verifies a chat request lands as a ContextRecord in the backend DB # with blob_status=uploaded. # -# Run from the repo root: ./scripts/smoke_e2e.sh +# Run from the repo root: ./test-scripts/smoke_e2e.sh set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/scripts/smoke_e2e_agent.sh b/test-scripts/smoke_e2e_agent.sh similarity index 100% rename from scripts/smoke_e2e_agent.sh rename to test-scripts/smoke_e2e_agent.sh diff --git a/scripts/smoke_e2e_real_tbox.sh b/test-scripts/smoke_e2e_real_tbox.sh similarity index 98% rename from scripts/smoke_e2e_real_tbox.sh rename to test-scripts/smoke_e2e_real_tbox.sh index 3e78804..24578e5 100755 --- a/scripts/smoke_e2e_real_tbox.sh +++ b/test-scripts/smoke_e2e_real_tbox.sh @@ -16,7 +16,7 @@ # AIMDWARE_TBOX_USER (default admin) # AIMDWARE_TBOX_PASS (default admin) # -# Run from the repo root: ./scripts/smoke_e2e_real_tbox.sh +# Run from the repo root: ./test-scripts/smoke_e2e_real_tbox.sh set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" From 1fba751075fc96eedab78a563bf4dfcab69a9b4b Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 08:10:12 +0800 Subject: [PATCH 19/60] fix(review): address 7 issues from Design A code review --- backend/alembic.ini | 149 +++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 55 +++++ backend/alembic/script.py.mako | 29 +++ .../1cc659f78871_0002_unique_session_turn.py | 41 ++++ .../ad7b66d6bff9_0001_initial_schema.py | 142 +++++++++++++ backend/src/aimdware_backend/admin_cli.py | 3 + backend/src/aimdware_backend/models.py | 22 +- backend/src/aimdware_backend/routes/admin.py | 9 +- backend/src/aimdware_backend/routes/ingest.py | 6 + backend/tests/test_models.py | 26 +++ llm-client/src/eviction.test.ts | 198 +++++++++--------- llm-client/src/eviction.ts | 37 ++-- llm-client/src/ingest-worker.test.ts | 40 ++++ llm-client/src/main-sync.test.ts | 148 +++++++++++++ llm-client/src/main.ts | 55 +++-- llm-client/src/queue.ts | 74 +++++-- llm-client/src/session.test.ts | 43 ++++ llm-client/src/session.ts | 22 +- 19 files changed, 945 insertions(+), 155 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/1cc659f78871_0002_unique_session_turn.py create mode 100644 backend/alembic/versions/ad7b66d6bff9_0001_initial_schema.py create mode 100644 llm-client/src/main-sync.test.ts 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..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..df87736 --- /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 + +from alembic import context + +# Importing the models registers every table with SQLModel.metadata. +from aimdware_backend import models # noqa: F401 +from aimdware_backend.settings import settings + +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/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index 1296628..9fae326 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -254,6 +254,9 @@ def _cmd_token_list(session: Session, args: argparse.Namespace) -> None: 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) diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py index e4be09a..35ea4a2 100644 --- a/backend/src/aimdware_backend/models.py +++ b/backend/src/aimdware_backend/models.py @@ -6,7 +6,7 @@ from typing import Optional from uuid import UUID, uuid4 -from sqlalchemy import BigInteger, Column, Index, JSON, LargeBinary +from sqlalchemy import BigInteger, Column, Index, JSON, LargeBinary, UniqueConstraint from sqlmodel import Field, SQLModel @@ -21,6 +21,18 @@ class Role(str, Enum): class BlobStatus(str, Enum): + """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" @@ -76,6 +88,14 @@ class StudentToken(SQLModel, table=True): 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) diff --git a/backend/src/aimdware_backend/routes/admin.py b/backend/src/aimdware_backend/routes/admin.py index b41d6a1..59c49c9 100644 --- a/backend/src/aimdware_backend/routes/admin.py +++ b/backend/src/aimdware_backend/routes/admin.py @@ -29,10 +29,17 @@ def get_jbox_reader() -> JboxReader: 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] + .order_by( + ContextRecord.turn_count.desc(), # type: ignore[union-attr] + ContextRecord.ts.desc(), # type: ignore[union-attr] + ) ).first() diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index a557a05..fa92483 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -137,6 +137,12 @@ def mark_uploaded( 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: diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 456c754..b4c30b4 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -144,3 +144,29 @@ def test_context_record_id_is_pk_unique(session: Session) -> None: 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, + 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() diff --git a/llm-client/src/eviction.test.ts b/llm-client/src/eviction.test.ts index 92ce8f3..9f36ddf 100644 --- a/llm-client/src/eviction.test.ts +++ b/llm-client/src/eviction.test.ts @@ -2,6 +2,7 @@ import { test, expect, afterEach } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync, existsSync } 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"; import { runEvictionOnce } from "./eviction"; @@ -17,150 +18,145 @@ function fresh() { return { d, cacheDir, recordsDir, q }; } -function body(id: string): IngestBody { +function body(record_id: string, session_id: string, turn_count = 1): IngestBody { return { - record_id: id, - session_id: `sess-${id}`, - turn_count: 1, + record_id, + session_id, + turn_count, course_code: "ECE4721J", blob_hash: "h", - blob_uri: `jbox://x/${id}.json`, + blob_uri: `jbox://x/${session_id}.json`, blob_size: 1, ts: "2026-05-12T00:00:00.000Z", router_version: "0.0.0", }; } -function setupDoneRecord( +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, - id: string, + record_id: string, + session_id: string, + turn_count: number, createdMs: number, -) { - // bypass enqueue's now() by inserting raw, then advance - q.enqueue(body(id), 0); - q.advance(id, "ingested", 0); - q.advance(id, "synced", 0); - q.advance(id, "done", 0); - // We need to backdate created_at; use raw sql for the test fixture. - const Database = require("bun:sqlite").Database; - const raw = new Database(join(recordsDir, "..", "queue.db")); - raw.exec(`UPDATE outbox SET created_at = ${createdMs} WHERE record_id = '${id}'`); - raw.close(); - writeFileSync(join(recordsDir, `${id}.json`), `payload-${id}`); +): 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 }); }); -test("runEvictionOnce deletes file + marks evicted for done records older than ttl", async () => { - const { cacheDir, recordsDir, q } = fresh(); - const NOW = 100_000_000; - const TTL_MS = 7 * 24 * 3600 * 1000; - setupDoneRecord(q, recordsDir, "old", NOW - TTL_MS - 1000); // older than ttl - setupDoneRecord(q, recordsDir, "fresh", NOW - 1000); // within ttl +const NOW = 100_000_000; +const TTL = 7 * 24 * 3600 * 1000; - const summary = await runEvictionOnce({ - queue: q, - cacheDir, - now: () => NOW, - ttlMs: TTL_MS, - }); +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); - expect(summary.evicted).toBe(1); - expect(existsSync(join(recordsDir, "old.json"))).toBe(false); - expect(existsSync(join(recordsDir, "fresh.json"))).toBe(true); - expect(q.isEvicted("old")).toBe(true); - expect(q.isEvicted("fresh")).toBe(false); + 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("runEvictionOnce ignores records not in 'done' state", async () => { +test("evicts a multi-turn session ONCE: deletes one file, marks all N records", async () => { const { cacheDir, recordsDir, q } = fresh(); - const NOW = 100_000_000; - const TTL_MS = 7 * 24 * 3600 * 1000; - // captured / ingested / synced — even if old, do not evict - q.enqueue(body("a"), 0); - q.enqueue(body("b"), 0); - q.advance("b", "ingested", 0); - q.enqueue(body("c"), 0); - q.advance("c", "ingested", 0); - q.advance("c", "synced", 0); - for (const id of ["a", "b", "c"]) { - const raw = new (require("bun:sqlite").Database)(join(cacheDir, "queue.db")); - raw.exec(`UPDATE outbox SET created_at = ${NOW - TTL_MS - 1000} WHERE record_id = '${id}'`); - raw.close(); - writeFileSync(join(recordsDir, `${id}.json`), "x"); - } - - const summary = await runEvictionOnce({ - queue: q, - cacheDir, - now: () => NOW, - ttlMs: TTL_MS, - }); + 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(); +}); - expect(summary.evicted).toBe(0); - for (const id of ["a", "b", "c"]) { - expect(existsSync(join(recordsDir, `${id}.json`))).toBe(true); - } +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("runEvictionOnce idempotent when file is already missing", async () => { +test("does NOT evict when the latest turn is still within TTL", async () => { const { cacheDir, recordsDir, q } = fresh(); - const NOW = 100_000_000; - const TTL_MS = 7 * 24 * 3600 * 1000; - setupDoneRecord(q, recordsDir, "ghost", NOW - TTL_MS - 1000); - rmSync(join(recordsDir, "ghost.json")); // simulate the file having been removed by hand + 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(); +}); - const summary = await runEvictionOnce({ - queue: q, - cacheDir, - now: () => NOW, - ttlMs: TTL_MS, - }); +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); - // Still counted as evicted; the row's cache_evicted flag is what we trust. - expect(summary.evicted).toBe(1); - expect(q.isEvicted("ghost")).toBe(true); + 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("runEvictionOnce skips records already marked cache_evicted", async () => { +test("tolerates a missing file (already removed out-of-band) — still marks records evicted", async () => { const { cacheDir, recordsDir, q } = fresh(); - const NOW = 100_000_000; - const TTL_MS = 7 * 24 * 3600 * 1000; - setupDoneRecord(q, recordsDir, "already", NOW - TTL_MS - 1000); - q.markEvicted("already"); - rmSync(join(recordsDir, "already.json")); + 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_MS, - }); - - expect(summary.evicted).toBe(0); + 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("runEvictionOnce respects limit", async () => { +test("limit caps the number of sessions per pass", async () => { const { cacheDir, recordsDir, q } = fresh(); - const NOW = 100_000_000; - const TTL_MS = 7 * 24 * 3600 * 1000; for (let i = 0; i < 5; i++) { - setupDoneRecord(q, recordsDir, `r${i}`, NOW - TTL_MS - 1000 - 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_MS, - limit: 2, + queue: q, cacheDir, now: () => NOW, ttlMs: TTL, limit: 2, }); - expect(summary.evicted).toBe(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/eviction.ts b/llm-client/src/eviction.ts index 44c1be8..6ffaca4 100644 --- a/llm-client/src/eviction.ts +++ b/llm-client/src/eviction.ts @@ -8,23 +8,30 @@ export type EvictionOpts = { cacheDir: string; now?: () => number; ttlMs?: number; + /** Max sessions to process per pass (not records). */ limit?: number; }; export type EvictionSummary = { - evicted: number; + sessions_evicted: number; + records_marked: number; }; const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000; const DEFAULT_LIMIT = 500; /** - * Run one eviction pass. Deletes `records/{id}.json` for done records - * older than `ttlMs`, then marks the queue row as `cache_evicted = 1`. - * Idempotent: a missing file still gets the row marked. + * Run one eviction pass over the cache directory. * - * The queue row itself is never deleted — it remains as a local audit - * trail. Eventual queue cleanup is a separate admin concern. + * 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 settled 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, @@ -34,20 +41,24 @@ export async function runEvictionOnce( const limit = opts.limit ?? DEFAULT_LIMIT; const threshold = now - ttlMs; - const ids = opts.queue.findEvictable(threshold, limit); - for (const id of ids) { + const sessions = opts.queue.findEvictableSessions(threshold, limit); + let records_marked = 0; + for (const s of sessions) { try { - await unlink(join(opts.cacheDir, "records", `${id}.json`)); + await unlink(join(opts.cacheDir, "records", `${s.session_id}.json`)); } catch (e) { - // ENOENT etc. — file already gone, still consider it evicted. const code = (e as NodeJS.ErrnoException).code; if (code !== "ENOENT") { - console.warn(`unlink failed for ${id}:`, (e as Error).message); + console.warn( + `unlink failed for session ${s.session_id}:`, + (e as Error).message, + ); } } - opts.queue.markEvicted(id); + for (const rid of s.record_ids) opts.queue.markEvicted(rid); + records_marked += s.record_ids.length; } - return { evicted: ids.length }; + return { sessions_evicted: sessions.length, records_marked }; } export type EvictionLoopHandle = { stop: () => Promise }; diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/ingest-worker.test.ts index 885472e..1e63f6c 100644 --- a/llm-client/src/ingest-worker.test.ts +++ b/llm-client/src/ingest-worker.test.ts @@ -174,3 +174,43 @@ test("processes the batch in parallel up to concurrency", async () => { } 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"]); + // All three records carry the same session_id throughout. + for (const id of ["t1", "t2", "t3"]) { + const stored = JSON.parse( + // raw body_json was set by enqueue; confirm session_id stuck. + // We don't expose a getter for body_json, so re-derive via pickReady. + "{}", + ); + expect(stored).toBeDefined(); // sanity + } + q.close(); +}); diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts new file mode 100644 index 0000000..c494dee --- /dev/null +++ b/llm-client/src/main-sync.test.ts @@ -0,0 +1,148 @@ +/** + * 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 "./sync"; +import type { IngestBody } from "./ingest"; +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", + blob_hash: "abc", + blob_uri: `aimdware/X/${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/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/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"); +}); diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index cd7fe60..f12ad75 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -32,29 +32,19 @@ function extractMessages(requestBytes: Uint8Array): Message[] { return []; } -function buildStages( - config: Config, +/** + * 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, -): 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 sync: StageHandler = async (body) => { - // Session-keyed cache file. May have been overwritten by a later turn - // of the same session — that's fine; we'll upload the latest state. +): StageHandler { + return async (body) => { const path = join(cacheDir, "records", `${body.session_id}.json`); let data: Uint8Array; try { @@ -76,6 +66,27 @@ function buildStages( 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( @@ -93,7 +104,7 @@ function buildStages( } }; - return { ingest, sync, confirm }; + return { ingest, sync: buildSyncStage(cacheDir, webdavPut), confirm }; } async function main() { diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index e53c6aa..099c999 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -42,6 +42,7 @@ export type ReadyRecord = { 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, @@ -55,6 +56,8 @@ const SCHEMA_STATEMENTS = [ ON outbox (state, next_attempt_at)`, `CREATE INDEX IF NOT EXISTS ix_outbox_evictable ON outbox (state, cache_evicted, created_at)`, + `CREATE INDEX IF NOT EXISTS ix_outbox_session + ON outbox (session_id)`, ]; export class IngestQueue { @@ -67,16 +70,40 @@ export class IngestQueue { // 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); + this.migrateSchema(); + } + + /** + * Idempotently add columns 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"); + 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, body_json, state, attempts, next_attempt_at, created_at) - VALUES (?, ?, 'captured', 0, ?, ?)`, + (record_id, session_id, body_json, state, attempts, next_attempt_at, created_at) + VALUES (?, ?, ?, 'captured', 0, ?, ?)`, ) - .run(body.record_id, JSON.stringify(body), nextAttemptAt, Date.now()); + .run( + body.record_id, + body.session_id, + JSON.stringify(body), + nextAttemptAt, + Date.now(), + ); } pickReady(now: number, limit: number): ReadyRecord[] { @@ -196,21 +223,42 @@ export class IngestQueue { } /** - * Return done records whose cache file is older than the threshold and - * not yet evicted. Capped by `limit`. + * Return sessions whose on-disk cache file is safe to delete. A session + * is evictable when: + * + * - every record in the session has state='done' (no in-flight turns + * that still need to read 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. */ - findEvictable(olderThanCreatedAt: number, limit: number): string[] { + findEvictableSessions( + olderThanCreatedAt: number, + limit: number, + ): Array<{ session_id: string; record_ids: string[] }> { const rows = this.db .prepare( - `SELECT record_id FROM outbox - WHERE state = 'done' - AND cache_evicted = 0 - AND created_at < ? - ORDER BY created_at ASC + `SELECT session_id, group_concat(record_id, ',') AS record_ids + FROM outbox + WHERE session_id IS NOT NULL + GROUP BY session_id + HAVING SUM(CASE WHEN state = 'done' THEN 0 ELSE 1 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 { record_id: string }[]; - return rows.map((r) => r.record_id); + .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(","), + })); } markEvicted(record_id: string): void { diff --git a/llm-client/src/session.test.ts b/llm-client/src/session.test.ts index 34302f0..48248dc 100644 --- a/llm-client/src/session.test.ts +++ b/llm-client/src/session.test.ts @@ -114,6 +114,49 @@ test("empty messages array produces a new session each time", () => { 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([ diff --git a/llm-client/src/session.ts b/llm-client/src/session.ts index 944e0bc..b4e8dae 100644 --- a/llm-client/src/session.ts +++ b/llm-client/src/session.ts @@ -101,8 +101,22 @@ function isPrefixOf(prior: Message[], next: Message[]): boolean { } function messagesEqual(a: Message, b: Message): boolean { - // JSON.stringify with stable key ordering would be safer; in practice - // OpenAI-compatible payloads have stable shapes from the client, so a - // naive stringify match is sufficient and avoids a deep-compare dep. - return JSON.stringify(a) === JSON.stringify(b); + // 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; + const out: Record = {}; + for (const k of Object.keys(src).sort()) out[k] = canonicalize(src[k]); + return out; } From e99f29c11df4c4c48401fce3367626345af2a647 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 08:22:00 +0800 Subject: [PATCH 20/60] fix(review2): close 2 Important + 1 Minor from second-round review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - queue.ts: ix_outbox_session was in SCHEMA_STATEMENTS, so on a legacy queue.db (created before session_id existed) the index DDL fired before migrateSchema() got a chance to ALTER ADD the column — constructor threw "no such column: session_id" and the upgrade path the migration was designed to support never ran. Moved the index creation into migrateSchema(), after the column is guaranteed. New test seeds a legacy DB and asserts the constructor auto-migrates. - ingest.py: UNIQUE(session_id, turn_count) raised IntegrityError up the stack as a 500. Wrapped session.commit() in try/except, return 409 with a descriptive detail instead. New route-level test that pre-empts a second record on the same session/turn. - ingest-worker.test.ts: replaced a tautological JSON.parse("{}") assertion with a real round-trip check that body_json preserves the shared session_id and per-turn turn_count via pickReady. Tests: llm-client 124 pass (+1 legacy migration test), backend 52 pass (+1 duplicate-turn test). Agent smoke against real Tbox PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/aimdware_backend/routes/ingest.py | 16 ++++++++- backend/tests/test_ingest.py | 25 ++++++++++++++ llm-client/src/ingest-worker.test.ts | 20 ++++++----- llm-client/src/queue.test.ts | 34 +++++++++++++++++++ llm-client/src/queue.ts | 19 +++++++---- 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index fa92483..c10dc65 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -7,6 +7,7 @@ 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 @@ -121,7 +122,20 @@ def post_context( blob_size=body.blob_size, ) session.add(record) - session.commit() + 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"} diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py index 51199ec..2320f84 100644 --- a/backend/tests/test_ingest.py +++ b/backend/tests/test_ingest.py @@ -225,3 +225,28 @@ def test_mark_uploaded_unknown_id_is_404( 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["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["turn_count"] = 1 + r2 = client.post("/ingest/context", json=body2, headers=h) + assert r2.status_code == 409, r2.text diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/ingest-worker.test.ts index 1e63f6c..96b7340 100644 --- a/llm-client/src/ingest-worker.test.ts +++ b/llm-client/src/ingest-worker.test.ts @@ -203,14 +203,16 @@ test("multi-turn session: independent records sharing one session_id both advanc expect(seen.ingest).toEqual(["t1"]); expect(seen.sync).toEqual(["t2"]); expect(seen.confirm).toEqual(["t3"]); - // All three records carry the same session_id throughout. - for (const id of ["t1", "t2", "t3"]) { - const stored = JSON.parse( - // raw body_json was set by enqueue; confirm session_id stuck. - // We don't expose a getter for body_json, so re-derive via pickReady. - "{}", - ); - expect(stored).toBeDefined(); // sanity - } + + // 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/queue.test.ts b/llm-client/src/queue.test.ts index 6062ff5..ea1a37c 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/queue.test.ts @@ -105,6 +105,40 @@ test("enqueue is idempotent on duplicate record_id", () => { 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 Database = require("bun:sqlite").Database; + 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); diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index 099c999..46bd116 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -56,8 +56,9 @@ const SCHEMA_STATEMENTS = [ ON outbox (state, next_attempt_at)`, `CREATE INDEX IF NOT EXISTS ix_outbox_evictable ON outbox (state, cache_evicted, created_at)`, - `CREATE INDEX IF NOT EXISTS ix_outbox_session - ON outbox (session_id)`, + // 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 { @@ -70,12 +71,14 @@ export class IngestQueue { // 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 missing on older databases. ALTER TABLE - * ADD COLUMN throws on duplicate, so we probe first via PRAGMA. + * 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 @@ -84,10 +87,12 @@ export class IngestQueue { 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"); - this.db.run( - "CREATE INDEX IF NOT EXISTS ix_outbox_session ON outbox (session_id)", - ); } + // 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 { From bb09bf4cc4c08e8410d353434a9c5b3dfc19002f Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 09:34:21 +0800 Subject: [PATCH 21/60] fix(review2): close 4 deferred Minor items - util.ts: new sessionBlobPath(cacheDir, session_id) helper shared by main.ts (write + sync read) and eviction.ts (unlink). Three call sites used to hand-roll the same path; now they can't drift. - session.ts: canonicalize uses Object.create(null) for the output map, so a "__proto__" key in a message writes a regular property instead of mutating the prototype chain. - queue.ts findEvictableSessions: group_concat separator is ASCII unit-separator (0x1F) instead of comma. Can't appear in a UUID record_id, so future-proofs against id-format changes. - backend conftest.py: after create_all, manually creates the partial unique index (active token per user) that Alembic 0001 owns but SQLModel.metadata can't express. Tests now see the same constraints as production. New test_student_token_active_per_user_partial_unique proves the constraint actually fires. Tests: llm-client 124 pass, backend 53 pass (+1). Agent smoke PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/tests/conftest.py | 15 ++++++++++++++- backend/tests/test_models.py | 31 +++++++++++++++++++++++++++++++ llm-client/src/eviction.ts | 5 ++--- llm-client/src/main.ts | 6 +++--- llm-client/src/queue.ts | 10 ++++++++-- llm-client/src/session.ts | 4 +++- llm-client/src/util.ts | 12 ++++++++++++ 7 files changed, 73 insertions(+), 10 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7bdef9e..6557295 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,7 +3,7 @@ import pytest from sqlmodel import Session, SQLModel, create_engine -from sqlalchemy import Engine +from sqlalchemy import Engine, text from sqlalchemy.pool import StaticPool # Importing models registers them with SQLModel.metadata so create_all @@ -25,6 +25,19 @@ def engine() -> Engine: 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 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index b4c30b4..e65e5a7 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -170,3 +170,34 @@ def test_context_record_unique_session_turn(session: Session) -> None: 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/llm-client/src/eviction.ts b/llm-client/src/eviction.ts index 6ffaca4..4b00edb 100644 --- a/llm-client/src/eviction.ts +++ b/llm-client/src/eviction.ts @@ -1,7 +1,6 @@ -import { join } from "node:path"; import { unlink } from "node:fs/promises"; import type { IngestQueue } from "./queue"; -import { StoppableSleep } from "./util"; +import { StoppableSleep, sessionBlobPath } from "./util"; export type EvictionOpts = { queue: IngestQueue; @@ -45,7 +44,7 @@ export async function runEvictionOnce( let records_marked = 0; for (const s of sessions) { try { - await unlink(join(opts.cacheDir, "records", `${s.session_id}.json`)); + await unlink(sessionBlobPath(opts.cacheDir, s.session_id)); } catch (e) { const code = (e as NodeJS.ErrnoException).code; if (code !== "ENOENT") { diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index f12ad75..388f173 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -10,7 +10,7 @@ import { IngestQueue } from "./queue"; import { startWorkerLoop, type Stages, type StageHandler } from "./ingest-worker"; import { postContext, confirmUploaded, type IngestBody } from "./ingest"; import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; -import { writeAtomic, bytesToHex, redactToken } from "./util"; +import { writeAtomic, bytesToHex, redactToken, sessionBlobPath } from "./util"; import { startEvictionLoop } from "./eviction"; import { tryParseJSON, decodeBytes } from "./capture"; import { SessionTracker, type Message } from "./session"; @@ -45,7 +45,7 @@ export function buildSyncStage( webdavPut: WebDAVPutLike, ): StageHandler { return async (body) => { - const path = join(cacheDir, "records", `${body.session_id}.json`); + const path = sessionBlobPath(cacheDir, body.session_id); let data: Uint8Array; try { data = new Uint8Array(await Bun.file(path).arrayBuffer()); @@ -174,7 +174,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); response_bytes: result.response_bytes, }); - const blobPath = join(recordsDir, `${cls.session_id}.json`); + const blobPath = sessionBlobPath(cacheDir, cls.session_id); try { await writeAtomic(blobPath, blob.blob_bytes); } catch (e) { diff --git a/llm-client/src/queue.ts b/llm-client/src/queue.ts index 46bd116..c86ffb0 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/queue.ts @@ -145,6 +145,9 @@ export class IngestQueue { 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 ( @@ -244,9 +247,12 @@ export class IngestQueue { 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, ',') AS record_ids + `SELECT session_id, group_concat(record_id, '${SEP}') AS record_ids FROM outbox WHERE session_id IS NOT NULL GROUP BY session_id @@ -262,7 +268,7 @@ export class IngestQueue { }>; return rows.map((r) => ({ session_id: r.session_id, - record_ids: r.record_ids.split(","), + record_ids: r.record_ids.split(SEP), })); } diff --git a/llm-client/src/session.ts b/llm-client/src/session.ts index b4e8dae..487ac32 100644 --- a/llm-client/src/session.ts +++ b/llm-client/src/session.ts @@ -116,7 +116,9 @@ 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; - const out: 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.ts b/llm-client/src/util.ts index b92341e..9dba805 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -1,4 +1,16 @@ import { rename } 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. From a6403c85996c1bb016600137fad7c5a2a60444aa Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Wed, 13 May 2026 09:53:29 +0800 Subject: [PATCH 22/60] style: clean up --- backend/src/aimdware_backend/routes/ingest.py | 2 +- llm-client/src/main.ts | 3 +-- llm-client/src/queue.test.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index c10dc65..5e4a54b 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -132,7 +132,7 @@ def post_context( raise HTTPException( status_code=409, detail=( - "duplicate (session_id, turn_count) — another record already " + "duplicate (session_id, turn_count): another record already " "claims this turn of this session" ), ) from None diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 388f173..46efa1d 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -140,8 +140,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const config = loadConfig(yamlText); const cacheDir = expandHome(config.local_cache_dir); - const recordsDir = join(cacheDir, "records"); - await mkdir(recordsDir, { recursive: true }); + await mkdir(join(cacheDir, "records"), { recursive: true }); const queueDb = join(cacheDir, "queue.db"); const queue = new IngestQueue(queueDb); diff --git a/llm-client/src/queue.test.ts b/llm-client/src/queue.test.ts index ea1a37c..b3c4d9a 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/queue.test.ts @@ -1,4 +1,5 @@ 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"; @@ -110,7 +111,6 @@ test("opening a legacy queue.db (no session_id column) auto-migrates without thr const dir = mkdtempSync(join(tmpdir(), "aimdware-queue-legacy-")); tmpDirs.push(dir); const path = join(dir, "queue.db"); - const Database = require("bun:sqlite").Database; const legacy = new Database(path); legacy.exec(` CREATE TABLE outbox ( From cbb76c3d68ea17c822692b564de8ec12d462f10a Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 10:20:35 +0800 Subject: [PATCH 23/60] refactor(llm-client): update prj hierachy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three domain subdirs that name real architectural seams, not descriptive groupings: - http/ — HTTP server boundary (server, handler, net, proxy). "http" because that's the actual current protocol edge; not "transport/http/" (over-abstraction for a hypothetical second protocol). - recording/— per-call capture + session correlation. Name borrowed from rrweb / OpenTelemetry / Sentry Replay, which use the same term for the same concept. - outbox/ — the transactional outbox pattern (Chris Richardson, Microservices Patterns). Was already the right vocabulary; just promoting it to a directory. Two file renames inside outbox/ to drop now-redundant prefixes: - ingest-worker.ts → relay.ts (the outbox-relay role) - ingest.ts → ingest-client.ts (HTTP client to backend) main.ts / config.ts / util.ts stay at src/ root (entry + shared). Tests stay co-located with their source files. Cross-directory imports are absolute-from-src (./outbox/relay, ../recording/capture, ../http/proxy etc.); same-dir imports keep their plain ./name form. Pure rename + path-fix; no behaviour change. typecheck clean, 124 llm-client tests pass, real-Tbox agent smoke still PASS. --- llm-client/src/{ => http}/handler.test.ts | 2 +- llm-client/src/{ => http}/handler.ts | 2 +- llm-client/src/{ => http}/net.test.ts | 0 llm-client/src/{ => http}/net.ts | 0 llm-client/src/{ => http}/proxy.test.ts | 0 llm-client/src/{ => http}/proxy.ts | 0 llm-client/src/{ => http}/server.test.ts | 0 llm-client/src/{ => http}/server.ts | 0 llm-client/src/main-sync.test.ts | 4 ++-- llm-client/src/main.ts | 20 +++++++++---------- llm-client/src/{ => outbox}/eviction.test.ts | 2 +- llm-client/src/{ => outbox}/eviction.ts | 2 +- .../ingest-client.test.ts} | 4 ++-- .../{ingest.ts => outbox/ingest-client.ts} | 4 ++-- .../src/{ => outbox}/queue.multiproc.test.ts | 2 +- llm-client/src/{ => outbox}/queue.test.ts | 2 +- llm-client/src/{ => outbox}/queue.ts | 2 +- .../relay.test.ts} | 4 ++-- .../src/{ingest-worker.ts => outbox/relay.ts} | 4 ++-- llm-client/src/{ => outbox}/sync.real.test.ts | 0 llm-client/src/{ => outbox}/sync.test.ts | 0 llm-client/src/{ => outbox}/sync.ts | 0 .../src/{ => recording}/capture.test.ts | 0 llm-client/src/{ => recording}/capture.ts | 0 .../src/{ => recording}/session-blob.test.ts | 0 .../src/{ => recording}/session-blob.ts | 0 .../src/{ => recording}/session.test.ts | 0 llm-client/src/{ => recording}/session.ts | 0 28 files changed, 27 insertions(+), 27 deletions(-) rename llm-client/src/{ => http}/handler.test.ts (98%) rename llm-client/src/{ => http}/handler.ts (95%) rename llm-client/src/{ => http}/net.test.ts (100%) rename llm-client/src/{ => http}/net.ts (100%) rename llm-client/src/{ => http}/proxy.test.ts (100%) rename llm-client/src/{ => http}/proxy.ts (100%) rename llm-client/src/{ => http}/server.test.ts (100%) rename llm-client/src/{ => http}/server.ts (100%) rename llm-client/src/{ => outbox}/eviction.test.ts (99%) rename llm-client/src/{ => outbox}/eviction.ts (97%) rename llm-client/src/{ingest.test.ts => outbox/ingest-client.test.ts} (98%) rename llm-client/src/{ingest.ts => outbox/ingest-client.ts} (97%) rename llm-client/src/{ => outbox}/queue.multiproc.test.ts (98%) rename llm-client/src/{ => outbox}/queue.test.ts (99%) rename llm-client/src/{ => outbox}/queue.ts (99%) rename llm-client/src/{ingest-worker.test.ts => outbox/relay.test.ts} (99%) rename llm-client/src/{ingest-worker.ts => outbox/relay.ts} (97%) rename llm-client/src/{ => outbox}/sync.real.test.ts (100%) rename llm-client/src/{ => outbox}/sync.test.ts (100%) rename llm-client/src/{ => outbox}/sync.ts (100%) rename llm-client/src/{ => recording}/capture.test.ts (100%) rename llm-client/src/{ => recording}/capture.ts (100%) rename llm-client/src/{ => recording}/session-blob.test.ts (100%) rename llm-client/src/{ => recording}/session-blob.ts (100%) rename llm-client/src/{ => recording}/session.test.ts (100%) rename llm-client/src/{ => recording}/session.ts (100%) diff --git a/llm-client/src/handler.test.ts b/llm-client/src/http/handler.test.ts similarity index 98% rename from llm-client/src/handler.test.ts rename to llm-client/src/http/handler.test.ts index ae6b0b8..a33d307 100644 --- a/llm-client/src/handler.test.ts +++ b/llm-client/src/http/handler.test.ts @@ -1,7 +1,7 @@ import { test, expect, afterEach, beforeEach } from "bun:test"; import type { Server } from "bun"; import { createHandler } from "./handler"; -import type { CaptureResult } from "./capture"; +import type { CaptureResult } from "../recording/capture"; let fakeUpstream: Server | undefined; diff --git a/llm-client/src/handler.ts b/llm-client/src/http/handler.ts similarity index 95% rename from llm-client/src/handler.ts rename to llm-client/src/http/handler.ts index 5b02f14..b4d53db 100644 --- a/llm-client/src/handler.ts +++ b/llm-client/src/http/handler.ts @@ -1,4 +1,4 @@ -import { captureChat, type CaptureResult } from "./capture"; +import { captureChat, type CaptureResult } from "../recording/capture"; import { proxyChat, type FetchLike, type UpstreamConfig } from "./proxy"; export type HandlerOpts = { diff --git a/llm-client/src/net.test.ts b/llm-client/src/http/net.test.ts similarity index 100% rename from llm-client/src/net.test.ts rename to llm-client/src/http/net.test.ts diff --git a/llm-client/src/net.ts b/llm-client/src/http/net.ts similarity index 100% rename from llm-client/src/net.ts rename to llm-client/src/http/net.ts diff --git a/llm-client/src/proxy.test.ts b/llm-client/src/http/proxy.test.ts similarity index 100% rename from llm-client/src/proxy.test.ts rename to llm-client/src/http/proxy.test.ts diff --git a/llm-client/src/proxy.ts b/llm-client/src/http/proxy.ts similarity index 100% rename from llm-client/src/proxy.ts rename to llm-client/src/http/proxy.ts diff --git a/llm-client/src/server.test.ts b/llm-client/src/http/server.test.ts similarity index 100% rename from llm-client/src/server.test.ts rename to llm-client/src/http/server.test.ts diff --git a/llm-client/src/server.ts b/llm-client/src/http/server.ts similarity index 100% rename from llm-client/src/server.ts rename to llm-client/src/http/server.ts diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts index c494dee..4cb4b1f 100644 --- a/llm-client/src/main-sync.test.ts +++ b/llm-client/src/main-sync.test.ts @@ -13,8 +13,8 @@ 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 "./sync"; -import type { IngestBody } from "./ingest"; +import type { WebDAVPutLike } from "./outbox/sync"; +import type { IngestBody } from "./outbox/ingest-client"; import { writeAtomic } from "./util"; const tmpDirs: string[] = []; diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 46efa1d..942bcb5 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -4,17 +4,17 @@ 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 "./handler"; -import { startServer } from "./server"; -import { IngestQueue } from "./queue"; -import { startWorkerLoop, type Stages, type StageHandler } from "./ingest-worker"; -import { postContext, confirmUploaded, type IngestBody } from "./ingest"; -import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./sync"; +import { createHandler } from "./http/handler"; +import { startServer } from "./http/server"; +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 { startEvictionLoop } from "./eviction"; -import { tryParseJSON, decodeBytes } from "./capture"; -import { SessionTracker, type Message } from "./session"; -import { buildSessionBlob } from "./session-blob"; +import { 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; diff --git a/llm-client/src/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts similarity index 99% rename from llm-client/src/eviction.test.ts rename to llm-client/src/outbox/eviction.test.ts index 9f36ddf..91cf9e1 100644 --- a/llm-client/src/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { IngestQueue } from "./queue"; -import type { IngestBody } from "./ingest"; +import type { IngestBody } from "./ingest-client"; import { runEvictionOnce } from "./eviction"; const tmpDirs: string[] = []; diff --git a/llm-client/src/eviction.ts b/llm-client/src/outbox/eviction.ts similarity index 97% rename from llm-client/src/eviction.ts rename to llm-client/src/outbox/eviction.ts index 4b00edb..b2ccb6a 100644 --- a/llm-client/src/eviction.ts +++ b/llm-client/src/outbox/eviction.ts @@ -1,6 +1,6 @@ import { unlink } from "node:fs/promises"; import type { IngestQueue } from "./queue"; -import { StoppableSleep, sessionBlobPath } from "./util"; +import { StoppableSleep, sessionBlobPath } from "../util"; export type EvictionOpts = { queue: IngestQueue; diff --git a/llm-client/src/ingest.test.ts b/llm-client/src/outbox/ingest-client.test.ts similarity index 98% rename from llm-client/src/ingest.test.ts rename to llm-client/src/outbox/ingest-client.test.ts index 8b2fbed..2959d49 100644 --- a/llm-client/src/ingest.test.ts +++ b/llm-client/src/outbox/ingest-client.test.ts @@ -1,6 +1,6 @@ import { test, expect, afterEach } from "bun:test"; import type { Server } from "bun"; -import { postContext, type IngestBody, type PostContextResult } from "./ingest"; +import { postContext, type IngestBody, type PostContextResult } from "./ingest-client"; let fakeBackend: Server | undefined; let lastReq: { body: string; auth: string | null; url: string } | undefined; @@ -117,7 +117,7 @@ test("network error -> retryable", async () => { }); // -------- confirmUploaded -------- -import { confirmUploaded } from "./ingest"; +import { confirmUploaded } from "./ingest-client"; test("confirmUploaded POSTs to /ingest/context/{id}/uploaded with bearer auth", async () => { const url = startFakeBackend(202); diff --git a/llm-client/src/ingest.ts b/llm-client/src/outbox/ingest-client.ts similarity index 97% rename from llm-client/src/ingest.ts rename to llm-client/src/outbox/ingest-client.ts index 7ceeb14..7f83653 100644 --- a/llm-client/src/ingest.ts +++ b/llm-client/src/outbox/ingest-client.ts @@ -1,5 +1,5 @@ -import { getProxyForUrl } from "./net"; -import type { FetchLike } from "./proxy"; +import { getProxyForUrl } from "../http/net"; +import type { FetchLike } from "../http/proxy"; export type IngestBody = { record_id: string; diff --git a/llm-client/src/queue.multiproc.test.ts b/llm-client/src/outbox/queue.multiproc.test.ts similarity index 98% rename from llm-client/src/queue.multiproc.test.ts rename to llm-client/src/outbox/queue.multiproc.test.ts index 8ca313c..34c5c53 100644 --- a/llm-client/src/queue.multiproc.test.ts +++ b/llm-client/src/outbox/queue.multiproc.test.ts @@ -3,7 +3,7 @@ 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"; +import type { IngestBody } from "./ingest-client"; const tmpDirs: string[] = []; diff --git a/llm-client/src/queue.test.ts b/llm-client/src/outbox/queue.test.ts similarity index 99% rename from llm-client/src/queue.test.ts rename to llm-client/src/outbox/queue.test.ts index b3c4d9a..a6c78d4 100644 --- a/llm-client/src/queue.test.ts +++ b/llm-client/src/outbox/queue.test.ts @@ -4,7 +4,7 @@ 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"; +import type { IngestBody } from "./ingest-client"; const tmpDirs: string[] = []; diff --git a/llm-client/src/queue.ts b/llm-client/src/outbox/queue.ts similarity index 99% rename from llm-client/src/queue.ts rename to llm-client/src/outbox/queue.ts index c86ffb0..e00ae6a 100644 --- a/llm-client/src/queue.ts +++ b/llm-client/src/outbox/queue.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import type { IngestBody } from "./ingest"; +import type { IngestBody } from "./ingest-client"; /** * Per-record lifecycle. diff --git a/llm-client/src/ingest-worker.test.ts b/llm-client/src/outbox/relay.test.ts similarity index 99% rename from llm-client/src/ingest-worker.test.ts rename to llm-client/src/outbox/relay.test.ts index 96b7340..ab53ef9 100644 --- a/llm-client/src/ingest-worker.test.ts +++ b/llm-client/src/outbox/relay.test.ts @@ -3,14 +3,14 @@ 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"; +import type { IngestBody } from "./ingest-client"; import { nextBackoff, DEFAULT_BACKOFF, runOnce, type Stages, type StageHandler, -} from "./ingest-worker"; +} from "./relay"; const tmpDirs: string[] = []; function freshQueue() { diff --git a/llm-client/src/ingest-worker.ts b/llm-client/src/outbox/relay.ts similarity index 97% rename from llm-client/src/ingest-worker.ts rename to llm-client/src/outbox/relay.ts index 600e57b..f72e6e3 100644 --- a/llm-client/src/ingest-worker.ts +++ b/llm-client/src/outbox/relay.ts @@ -1,6 +1,6 @@ import type { IngestQueue, RecordState } from "./queue"; -import type { IngestBody } from "./ingest"; -import { StoppableSleep } from "./util"; +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, diff --git a/llm-client/src/sync.real.test.ts b/llm-client/src/outbox/sync.real.test.ts similarity index 100% rename from llm-client/src/sync.real.test.ts rename to llm-client/src/outbox/sync.real.test.ts diff --git a/llm-client/src/sync.test.ts b/llm-client/src/outbox/sync.test.ts similarity index 100% rename from llm-client/src/sync.test.ts rename to llm-client/src/outbox/sync.test.ts diff --git a/llm-client/src/sync.ts b/llm-client/src/outbox/sync.ts similarity index 100% rename from llm-client/src/sync.ts rename to llm-client/src/outbox/sync.ts diff --git a/llm-client/src/capture.test.ts b/llm-client/src/recording/capture.test.ts similarity index 100% rename from llm-client/src/capture.test.ts rename to llm-client/src/recording/capture.test.ts diff --git a/llm-client/src/capture.ts b/llm-client/src/recording/capture.ts similarity index 100% rename from llm-client/src/capture.ts rename to llm-client/src/recording/capture.ts diff --git a/llm-client/src/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts similarity index 100% rename from llm-client/src/session-blob.test.ts rename to llm-client/src/recording/session-blob.test.ts diff --git a/llm-client/src/session-blob.ts b/llm-client/src/recording/session-blob.ts similarity index 100% rename from llm-client/src/session-blob.ts rename to llm-client/src/recording/session-blob.ts diff --git a/llm-client/src/session.test.ts b/llm-client/src/recording/session.test.ts similarity index 100% rename from llm-client/src/session.test.ts rename to llm-client/src/recording/session.test.ts diff --git a/llm-client/src/session.ts b/llm-client/src/recording/session.ts similarity index 100% rename from llm-client/src/session.ts rename to llm-client/src/recording/session.ts From 78c1f5b48dc879dc0d5e83ba8333240a14149053 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 10:34:33 +0800 Subject: [PATCH 24/60] chore: update prj ci --- .pre-commit-config.yaml | 47 +++++++++ backend/alembic/README | 2 +- backend/alembic/env.py | 4 +- backend/pyproject.toml | 19 ++++ backend/scripts/seed_for_e2e.py | 17 +--- backend/src/aimdware_backend/admin_auth.py | 3 +- backend/src/aimdware_backend/admin_cli.py | 34 ++----- backend/src/aimdware_backend/auth.py | 1 + backend/src/aimdware_backend/db.py | 8 +- backend/src/aimdware_backend/jbox.py | 7 +- backend/src/aimdware_backend/main.py | 1 + backend/src/aimdware_backend/models.py | 22 ++--- backend/src/aimdware_backend/routes/admin.py | 18 +--- backend/src/aimdware_backend/routes/ingest.py | 10 +- backend/src/aimdware_backend/settings.py | 1 + backend/tests/conftest.py | 3 +- backend/tests/test_admin.py | 37 ++----- backend/tests/test_admin_cli.py | 41 +++----- backend/tests/test_auth.py | 21 ++-- backend/tests/test_ingest.py | 9 +- backend/tests/test_jbox_real.py | 10 +- backend/tests/test_models.py | 5 +- backend/uv.lock | 53 ++++++++++ llm-client/.gitignore | 1 - llm-client/.oxfmtrc.json | 24 +++++ llm-client/.oxlintrc.json | 15 +++ llm-client/bun.lock | 84 ++++++++++++++++ llm-client/package.json | 5 + llm-client/src/config.ts | 3 +- llm-client/src/http/handler.test.ts | 8 +- llm-client/src/http/proxy.test.ts | 19 +++- llm-client/src/http/server.test.ts | 3 +- llm-client/src/main-sync.test.ts | 9 +- llm-client/src/main.test.ts | 16 ++- llm-client/src/main.ts | 27 +++++- llm-client/src/outbox/eviction.test.ts | 90 ++++++++++++++--- llm-client/src/outbox/ingest-client.test.ts | 12 +-- llm-client/src/outbox/queue.multiproc.test.ts | 18 +++- llm-client/src/outbox/queue.test.ts | 15 ++- llm-client/src/outbox/queue.ts | 34 ++++--- llm-client/src/outbox/relay.test.ts | 53 +++++++--- llm-client/src/outbox/relay.ts | 97 +++++++++++++------ llm-client/src/outbox/sync.real.test.ts | 25 +++-- llm-client/src/outbox/sync.ts | 10 +- llm-client/src/recording/capture.test.ts | 12 ++- llm-client/src/recording/session-blob.test.ts | 4 +- llm-client/src/recording/session.test.ts | 12 ++- llm-client/src/util.test.ts | 3 +- llm-client/src/util.ts | 2 - 49 files changed, 679 insertions(+), 295 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 llm-client/.oxfmtrc.json create mode 100644 llm-client/.oxlintrc.json 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/backend/alembic/README b/backend/alembic/README index 98e4f9c..2500aa1 100644 --- a/backend/alembic/README +++ b/backend/alembic/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index df87736..191a3e5 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,15 +1,15 @@ """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 -from alembic import context - # 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: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a1cceda..2e6f363 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,8 +21,27 @@ dev = [ "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" diff --git a/backend/scripts/seed_for_e2e.py b/backend/scripts/seed_for_e2e.py index 4f6f607..50a7d31 100644 --- a/backend/scripts/seed_for_e2e.py +++ b/backend/scripts/seed_for_e2e.py @@ -11,6 +11,7 @@ AIMDWARE_DATABASE_URL=sqlite:///./aimdware.db \ uv run python scripts/seed_for_e2e.py """ + from __future__ import annotations import hashlib @@ -74,23 +75,13 @@ def main() -> int: ) ).first() if enrol is None: - s.add( - Enrollment( - user_id=user.id, course_id=course.id, role=Role.student - ) - ) + 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() + 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.add(StudentToken(user_id=user.id, token_hash=digest, prefix=plaintext[:8])) s.commit() print(plaintext, end="") diff --git a/backend/src/aimdware_backend/admin_auth.py b/backend/src/aimdware_backend/admin_auth.py index b5fe7ee..132acbc 100644 --- a/backend/src/aimdware_backend/admin_auth.py +++ b/backend/src/aimdware_backend/admin_auth.py @@ -1,4 +1,5 @@ """Shared-secret bearer auth for /admin/* endpoints.""" + from __future__ import annotations import hmac @@ -28,7 +29,7 @@ def authenticate_admin( detail="missing admin bearer", headers={"WWW-Authenticate": "Bearer"}, ) - token = authorization[len("bearer "):].strip() + token = authorization[len("bearer ") :].strip() if not hmac.compare_digest(token, settings.admin_secret): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index 9fae326..6aca18c 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -12,6 +12,7 @@ token issue / token revoke / token list record list / record payload """ + from __future__ import annotations import argparse @@ -38,13 +39,10 @@ utcnow, ) - # --- importable command functions (also covered by tests) --------------- -def user_create( - session: Session, *, jaccount: str, email: str, display_name: str -) -> User: +def user_create(session: Session, *, jaccount: str, email: str, display_name: str) -> User: """Create a new User row and return it.""" user = User(jaccount=jaccount, email=email, display_name=display_name) session.add(user) @@ -61,9 +59,7 @@ def user_get(session: Session, jaccount: str) -> User: return user -def course_create( - session: Session, *, code: str, title: str, semester: str -) -> Course: +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) @@ -179,12 +175,8 @@ def _cmd_user_list(session: Session, _args: argparse.Namespace) -> None: 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} - ) + 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: @@ -221,9 +213,7 @@ def _cmd_enroll(session: Session, args: argparse.Namespace) -> None: def _cmd_token_issue(session: Session, args: argparse.Namespace) -> None: 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)} - ) + _print_json({"plaintext": plaintext, "prefix": tok.prefix, "id": str(tok.id)}) def _cmd_token_revoke(session: Session, args: argparse.Namespace) -> None: @@ -244,9 +234,7 @@ def _cmd_token_list(session: Session, args: argparse.Namespace) -> None: "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 - ), + "revoked_at": (t.revoked_at.isoformat() if t.revoked_at else None), } for t in rows ] @@ -284,9 +272,7 @@ def _cmd_record_list(session: Session, args: argparse.Namespace) -> None: ) -async def _cmd_record_payload( - session: Session, args: argparse.Namespace -) -> None: +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 @@ -360,9 +346,7 @@ def build_parser() -> argparse.ArgumentParser: p_ti.add_argument("--user", required=True, help="user jaccount") p_ti.set_defaults(func=_cmd_token_issue) p_tr = s_tok.add_parser("revoke") - p_tr.add_argument( - "--prefix", required=True, help="8-char prefix shown when issued" - ) + p_tr.add_argument("--prefix", required=True, help="8-char prefix shown when issued") p_tr.set_defaults(func=_cmd_token_revoke) p_tl = s_tok.add_parser("list") p_tl.add_argument("--user", default=None) diff --git a/backend/src/aimdware_backend/auth.py b/backend/src/aimdware_backend/auth.py index b4b10bb..a07551f 100644 --- a/backend/src/aimdware_backend/auth.py +++ b/backend/src/aimdware_backend/auth.py @@ -1,4 +1,5 @@ """Token-hash bearer auth for /ingest/*.""" + from __future__ import annotations import hashlib diff --git a/backend/src/aimdware_backend/db.py b/backend/src/aimdware_backend/db.py index 6c80e6d..9d2ac0d 100644 --- a/backend/src/aimdware_backend/db.py +++ b/backend/src/aimdware_backend/db.py @@ -1,14 +1,14 @@ """Database engine + session dependency.""" + from __future__ import annotations -from typing import Iterator +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 @@ -19,9 +19,7 @@ def get_engine() -> Engine: _engine = create_engine( settings.database_url, connect_args=( - {"check_same_thread": False} - if settings.database_url.startswith("sqlite") - else {} + {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} ), ) return _engine diff --git a/backend/src/aimdware_backend/jbox.py b/backend/src/aimdware_backend/jbox.py index 8ed4963..0a676a8 100644 --- a/backend/src/aimdware_backend/jbox.py +++ b/backend/src/aimdware_backend/jbox.py @@ -1,4 +1,5 @@ """WebDAV-via-Tbox blob reader.""" + from __future__ import annotations from typing import Protocol @@ -46,9 +47,5 @@ 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 - ) + 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 index 9cecf0e..4289c8f 100644 --- a/backend/src/aimdware_backend/main.py +++ b/backend/src/aimdware_backend/main.py @@ -1,4 +1,5 @@ """FastAPI app factory.""" + from __future__ import annotations from fastapi import FastAPI diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py index 35ea4a2..a8cb0f1 100644 --- a/backend/src/aimdware_backend/models.py +++ b/backend/src/aimdware_backend/models.py @@ -1,26 +1,26 @@ """SQLModel schemas for the aimdware backend.""" + from __future__ import annotations -from datetime import datetime, timezone -from enum import Enum -from typing import Optional +from datetime import UTC, datetime +from enum import StrEnum from uuid import UUID, uuid4 -from sqlalchemy import BigInteger, Column, Index, JSON, LargeBinary, UniqueConstraint +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(timezone.utc) + return datetime.now(UTC) -class Role(str, Enum): +class Role(StrEnum): student = "student" admin = "admin" -class BlobStatus(str, Enum): +class BlobStatus(StrEnum): """Lifecycle of the per-record blob on jbox. Note on multi-turn sessions: the blob file is keyed by `session_id` @@ -78,7 +78,7 @@ class StudentToken(SQLModel, table=True): token_hash: bytes = Field(sa_column=Column(LargeBinary)) prefix: str created_at: datetime = Field(default_factory=utcnow) - revoked_at: Optional[datetime] = None + 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 @@ -92,9 +92,7 @@ class ContextRecord(SQLModel, table=True): # 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" - ), + UniqueConstraint("session_id", "turn_count", name="ux_context_records_session_turn"), ) id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -116,4 +114,4 @@ class ContextRecord(SQLModel, table=True): 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 + blob_verified_at: datetime | None = None diff --git a/backend/src/aimdware_backend/routes/admin.py b/backend/src/aimdware_backend/routes/admin.py index 59c49c9..0a715ad 100644 --- a/backend/src/aimdware_backend/routes/admin.py +++ b/backend/src/aimdware_backend/routes/admin.py @@ -1,4 +1,5 @@ """Admin endpoints — TT-facing, shared-secret bearer auth.""" + from __future__ import annotations import hashlib @@ -13,7 +14,6 @@ from aimdware_backend.jbox import JboxNotFound, JboxReader, default_reader from aimdware_backend.models import ContextRecord - router = APIRouter( prefix="/admin", tags=["admin"], @@ -26,9 +26,7 @@ def get_jbox_reader() -> JboxReader: return default_reader() -def _latest_record_for_session( - session: Session, session_id: UUID -) -> ContextRecord | None: +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, @@ -43,19 +41,13 @@ def _latest_record_for_session( ).first() -async def _fetch_and_verify( - record: ContextRecord, reader: JboxReader -) -> tuple[bytes, bool]: +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 + 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 + raise HTTPException(status_code=502, detail=f"jbox fetch failed: {exc}") from None actual = hashlib.sha256(bytes_).digest() return bytes_, actual == record.blob_hash diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index 5e4a54b..f84da3f 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -1,4 +1,5 @@ """Ingest API — the only HTTP surface the student router talks to.""" + from __future__ import annotations from datetime import datetime @@ -21,7 +22,6 @@ User, ) - router = APIRouter(prefix="/ingest", tags=["ingest"]) @@ -48,9 +48,7 @@ class IngestContextBody(BaseModel): client_meta: dict[str, Any] = Field(default_factory=dict) -def _resolve_enrolled_course( - session: Session, user: User, course_code: str -) -> Course: +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") @@ -62,9 +60,7 @@ def _resolve_enrolled_course( ) ).first() if enrol is None: - raise HTTPException( - status_code=403, detail="not enrolled as student in this course" - ) + raise HTTPException(status_code=403, detail="not enrolled as student in this course") return course diff --git a/backend/src/aimdware_backend/settings.py b/backend/src/aimdware_backend/settings.py index ad65cb9..4ed12b2 100644 --- a/backend/src/aimdware_backend/settings.py +++ b/backend/src/aimdware_backend/settings.py @@ -1,4 +1,5 @@ """Process-wide settings, populated from env vars.""" + from __future__ import annotations from pydantic_settings import BaseSettings, SettingsConfigDict diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6557295..55da710 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,10 +1,11 @@ """Shared pytest fixtures.""" + from __future__ import annotations import pytest -from sqlmodel import Session, SQLModel, create_engine 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. diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index fdf098b..bec0647 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -1,4 +1,5 @@ """TDD: /admin/context/{id}/payload.""" + from __future__ import annotations import hashlib @@ -95,9 +96,7 @@ 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") - ) + r = client.get(f"/admin/context/{rec.id}/payload", headers=_auth("wrong-secret")) assert r.status_code == 401 @@ -115,9 +114,7 @@ def test_admin_disabled_when_secret_unset( # --- happy path / verify --- -def test_payload_returns_verified_true_when_hash_matches( - engine: Engine, session: Session -) -> None: +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) @@ -152,35 +149,27 @@ def test_payload_returns_verified_false_when_hash_mismatches( assert body["payload_utf8"] == tampered.decode("utf-8") -def test_payload_returns_404_when_record_unknown( - engine: Engine, session: Session -) -> None: +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: +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: +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: +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) @@ -197,9 +186,7 @@ def test_payload_endpoint_does_not_mutate_status( # ---------- session-level payload endpoint ---------- -def _seed_session( - session: Session, *, turns: list[bytes] -) -> tuple[list[ContextRecord], bytes]: +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 @@ -249,17 +236,13 @@ def test_session_payload_verifies_against_latest_turn_hash( assert body["payload_utf8"] == current_on_jbox.decode() -def test_session_payload_404_when_session_unknown( - engine: Engine, session: Session -) -> None: +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: +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}'] diff --git a/backend/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py index 1075d14..7dbb0cf 100644 --- a/backend/tests/test_admin_cli.py +++ b/backend/tests/test_admin_cli.py @@ -1,4 +1,5 @@ """Tests for the TT admin CLI's importable command functions.""" + from __future__ import annotations import hashlib @@ -17,9 +18,7 @@ def test_user_create_adds_row(session: Session) -> None: - u = user_create( - session, jaccount="alice", email="alice@sjtu.edu.cn", display_name="Alice" - ) + 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 @@ -35,36 +34,24 @@ def test_user_get_raises_when_missing(session: Session) -> None: def test_course_create_adds_row(session: Session) -> None: - c = course_create( - session, code="VE477", title="Algorithms", semester="2026-fall" - ) + 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 - ) + 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" - ) + 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 @@ -73,9 +60,7 @@ def test_enroll_is_idempotent(session: Session) -> None: 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" - ) + 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] @@ -83,9 +68,7 @@ def test_token_issue_stores_sha256_hash_and_returns_plaintext( def test_token_issue_revokes_prior_active_token(session: Session) -> None: - user_create( - session, jaccount="eve", email="eve@sjtu.edu.cn", display_name="Eve" - ) + 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) @@ -99,9 +82,7 @@ def test_token_issue_revokes_prior_active_token(session: Session) -> None: def test_token_revoke_by_prefix(session: Session) -> None: - user_create( - session, jaccount="frank", email="frank@sjtu.edu.cn", display_name="Frank" - ) + 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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 2efe6e0..77744ee 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,7 +1,10 @@ """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 @@ -23,7 +26,7 @@ def app(engine: Engine): a = FastAPI() @a.get("/me") - def me(user: User = Depends(authenticate_student)): # noqa: ANN001 + def me(user: Annotated[User, Depends(authenticate_student)]): # noqa: ANN201 return {"jaccount": user.jaccount} def override_session(): @@ -43,9 +46,7 @@ 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.add(StudentToken(user_id=user.id, token_hash=_hash(plaintext), prefix=plaintext[:8])) session.commit() return user @@ -77,12 +78,12 @@ 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, timezone + from datetime import datetime for row in session.exec( # noqa: SLF001 — test __import__("sqlmodel").select(StudentToken) ).all(): - row.revoked_at = datetime.now(timezone.utc) + row.revoked_at = datetime.now(UTC) session.add(row) session.commit() @@ -100,14 +101,12 @@ def test_inactive_user_is_401(client: TestClient, session: Session) -> None: assert r.status_code == 401 -def test_one_active_token_per_user_lookup_picks_it( - client: TestClient, session: Session -) -> None: +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, timezone + from datetime import datetime # old token, revoked session.add( @@ -115,7 +114,7 @@ def test_one_active_token_per_user_lookup_picks_it( user_id=user.id, token_hash=_hash("st_OLD_REVOKED"), prefix="st_OLD_R", - revoked_at=datetime.now(timezone.utc), + revoked_at=datetime.now(UTC), ) ) # current active token diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py index 2320f84..218f095 100644 --- a/backend/tests/test_ingest.py +++ b/backend/tests/test_ingest.py @@ -1,8 +1,9 @@ """TDD: ingest endpoints.""" + from __future__ import annotations import hashlib -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -40,9 +41,7 @@ def enrolled_student(session: Session) -> tuple[str, 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.add(StudentToken(user_id=user.id, token_hash=_hash(plaintext), prefix=plaintext[:8])) session.commit() return plaintext, user, course @@ -59,7 +58,7 @@ def _body(course_code: str = "ECE4721J", **overrides) -> dict: "model": "gpt-4o-mini", "prompt_tokens": 10, "completion_tokens": 20, - "ts": datetime.now(timezone.utc).isoformat(), + "ts": datetime.now(UTC).isoformat(), "router_version": "0.0.0", "client_meta": {"agent": "cline"}, } diff --git a/backend/tests/test_jbox_real.py b/backend/tests/test_jbox_real.py index e9530e6..2edf336 100644 --- a/backend/tests/test_jbox_real.py +++ b/backend/tests/test_jbox_real.py @@ -5,8 +5,10 @@ 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 @@ -29,9 +31,7 @@ def _reachable() -> bool: return False -pytestmark = pytest.mark.skipif( - not _reachable(), reason=f"Tbox not reachable at {TBOX_URL}" -) +pytestmark = pytest.mark.skipif(not _reachable(), reason=f"Tbox not reachable at {TBOX_URL}") @pytest.fixture() @@ -49,10 +49,8 @@ def seeded_blob() -> Iterator[tuple[str, bytes]]: yield path, payload # Cleanup (best effort). - try: + with contextlib.suppress(httpx.HTTPError): httpx.delete(f"{TBOX_URL}/{subdir}", auth=auth) - except httpx.HTTPError: - pass @pytest.mark.asyncio diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index e65e5a7..2955c1e 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,4 +1,5 @@ """TDD: SQLModel schemas.""" + from __future__ import annotations from uuid import uuid4 @@ -49,9 +50,7 @@ def test_user_jaccount_unique(session: Session) -> None: def test_course_code_unique(session: Session) -> None: - session.add( - Course(code="ECE4721J", title="Intro to Systems", semester="2026-spring") - ) + 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): diff --git a/backend/uv.lock b/backend/uv.lock index c046a1e..3b1571f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -19,8 +19,10 @@ dependencies = [ dev = [ { name = "anyio" }, { name = "httpx" }, + { name = "prek" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] @@ -37,8 +39,10 @@ requires-dist = [ 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]] @@ -348,6 +352,30 @@ 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" @@ -545,6 +573,31 @@ wheels = [ { 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" diff --git a/llm-client/.gitignore b/llm-client/.gitignore index d451c1b..dd6e803 100644 --- a/llm-client/.gitignore +++ b/llm-client/.gitignore @@ -2,4 +2,3 @@ node_modules/ dist/ *.log .DS_Store - 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/bun.lock b/llm-client/bun.lock index 454e1f4..e5e6f2c 100644 --- a/llm-client/bun.lock +++ b/llm-client/bun.lock @@ -11,6 +11,8 @@ }, "devDependencies": { "@types/bun": "^1.1.10", + "oxfmt": "^0.49.0", + "oxlint": "^1.41.0", "typescript": "^5.6.3", }, }, @@ -20,6 +22,82 @@ "@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=="], @@ -66,6 +144,10 @@ "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=="], @@ -76,6 +158,8 @@ "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=="], diff --git a/llm-client/package.json b/llm-client/package.json index 02da65f..31033a9 100644 --- a/llm-client/package.json +++ b/llm-client/package.json @@ -7,6 +7,9 @@ "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", @@ -23,6 +26,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.ts b/llm-client/src/config.ts index 9888b88..fd4dbed 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -29,7 +29,6 @@ export function loadConfig(yamlText: string): Config { const parsed = RawConfigSchema.parse(raw); return { ...parsed, - jbox_remote_path: - parsed.jbox_remote_path ?? `aimdware/${parsed.course}`, + jbox_remote_path: parsed.jbox_remote_path ?? `aimdware/${parsed.course}`, }; } diff --git a/llm-client/src/http/handler.test.ts b/llm-client/src/http/handler.test.ts index a33d307..398c27b 100644 --- a/llm-client/src/http/handler.test.ts +++ b/llm-client/src/http/handler.test.ts @@ -105,7 +105,9 @@ test("POST /v1/chat/completions fires onCapture in background with full blob", a expect(captured).toBeDefined(); expect(captured!.upstream_status).toBe(200); - expect(new TextDecoder().decode(captured!.request_bytes)).toContain('"model":"gpt-4o"'); + 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" }); }); @@ -150,5 +152,7 @@ test("/v1/chat/completions streams response while still capturing", async () => // 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("")); + expect(new TextDecoder().decode(captured!.response_bytes)).toBe( + chunks.join(""), + ); }); diff --git a/llm-client/src/http/proxy.test.ts b/llm-client/src/http/proxy.test.ts index 7a9d893..82b0452 100644 --- a/llm-client/src/http/proxy.test.ts +++ b/llm-client/src/http/proxy.test.ts @@ -58,7 +58,10 @@ afterEach(async () => { 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" } }), + () => + new Response('{"id":"x"}', { + headers: { "content-type": "application/json" }, + }), ); const inbound = new Request("http://localhost/v1/chat/completions", { @@ -67,7 +70,10 @@ test("proxyChat forwards body and rewrites Authorization to upstream's api_key", "content-type": "application/json", authorization: "Bearer router-side-junk", }, - body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }), + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + }), }); await proxyChat(inbound, { base_url: baseUrl, api_key: "sk-upstream" }); @@ -83,7 +89,11 @@ test("proxyChat forwards body and rewrites Authorization to upstream's api_key", 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" } }), + () => + new Response('{"echo":"ok"}', { + status: 201, + headers: { "content-type": "application/json" }, + }), ); const res = await proxyChat( @@ -100,7 +110,8 @@ test("proxyChat returns upstream status and body verbatim for non-stream", async }); test("proxyChat passes proxy from HTTPS_PROXY env to fetch", async () => { - const calls: Array<{ url: string; init: RequestInit & { proxy?: string } }> = []; + 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(), diff --git a/llm-client/src/http/server.test.ts b/llm-client/src/http/server.test.ts index d632b27..a10736a 100644 --- a/llm-client/src/http/server.test.ts +++ b/llm-client/src/http/server.test.ts @@ -11,7 +11,8 @@ afterEach(async () => { 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 }), + async (req) => + new Response(`echo: ${new URL(req.url).pathname}`, { status: 200 }), ); const res = await fetch(`http://127.0.0.1:${handle.port}/anything`); diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts index 4cb4b1f..de1be0d 100644 --- a/llm-client/src/main-sync.test.ts +++ b/llm-client/src/main-sync.test.ts @@ -26,7 +26,8 @@ function fresh() { return { cacheDir }; } afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); function recordingPut(): { @@ -40,7 +41,11 @@ function recordingPut(): { return { put, uploads }; } -function body(record_id: string, session_id: string, turn_count: number): IngestBody { +function body( + record_id: string, + session_id: string, + turn_count: number, +): IngestBody { return { record_id, session_id, diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts index d8820f1..b653908 100644 --- a/llm-client/src/main.test.ts +++ b/llm-client/src/main.test.ts @@ -79,11 +79,17 @@ backend_url: http://127.0.0.1:1 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" }] }), - }); + 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 { diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 942bcb5..991596e 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -7,8 +7,16 @@ import { loadConfig, type Config } from "./config"; import { createHandler } from "./http/handler"; import { startServer } from "./http/server"; import { IngestQueue } from "./outbox/queue"; -import { startWorkerLoop, type Stages, type StageHandler } from "./outbox/relay"; -import { postContext, confirmUploaded, type IngestBody } from "./outbox/ingest-client"; +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 { startEvictionLoop } from "./outbox/eviction"; @@ -80,7 +88,11 @@ function buildStages( case "exists": return { kind: "advance" }; case "conflict": - return { kind: "terminal", finalState: "conflict", reason: "body mismatch" }; + return { + kind: "terminal", + finalState: "conflict", + reason: "body mismatch", + }; case "fatal": return { kind: "terminal", finalState: "fatal", reason: r.reason }; case "retryable": @@ -134,7 +146,10 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); try { yamlText = await readFile(configPath, "utf-8"); } catch (e) { - console.error(`failed to read config at ${configPath}:`, (e as Error).message); + console.error( + `failed to read config at ${configPath}:`, + (e as Error).message, + ); process.exit(1); } @@ -223,7 +238,9 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); console.log( `aimdware-router listening on http://${handle.hostname}:${handle.port}`, ); - console.log(` upstream: ${config.upstream.base_url} (${config.upstream.type})`); + console.log( + ` upstream: ${config.upstream.base_url} (${config.upstream.type})`, + ); console.log(` upstream key: ${redactToken(config.upstream.api_key)}`); console.log(` student token: ${redactToken(config.student_token)}`); console.log(` course: ${config.course}`); diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts index 91cf9e1..fe48a5d 100644 --- a/llm-client/src/outbox/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -18,7 +18,11 @@ function fresh() { return { d, cacheDir, recordsDir, q }; } -function body(record_id: string, session_id: string, turn_count = 1): IngestBody { +function body( + record_id: string, + session_id: string, + turn_count = 1, +): IngestBody { return { record_id, session_id, @@ -34,7 +38,9 @@ function body(record_id: string, session_id: string, turn_count = 1): IngestBody 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.exec( + `UPDATE outbox SET created_at = ${createdMs} WHERE record_id = '${record_id}'`, + ); raw.close(); } @@ -54,11 +60,15 @@ function setupDoneTurn( 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}`); + writeFileSync( + join(recordsDir, `${session_id}.json`), + `payload-${session_id}`, + ); } afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); const NOW = 100_000_000; @@ -68,7 +78,12 @@ test("evicts a single-turn session past TTL: deletes .json + marks r 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 }); + 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); @@ -81,7 +96,12 @@ test("evicts a multi-turn session ONCE: deletes one file, marks all N records", 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 }); + 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); @@ -96,7 +116,12 @@ test("does NOT evict a session that still has in-flight turns", async () => { 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 }); + 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); @@ -108,7 +133,12 @@ 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 }); + 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(); @@ -118,9 +148,19 @@ 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 }); + 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 }); + const second = await runEvictionOnce({ + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + }); expect(second.sessions_evicted).toBe(0); q.close(); }); @@ -130,7 +170,12 @@ test("tolerates a missing file (already removed out-of-band) — still marks rec 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 }); + 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(); @@ -139,10 +184,22 @@ test("tolerates a missing file (already removed out-of-band) — still marks rec 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); + 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, + queue: q, + cacheDir, + now: () => NOW, + ttlMs: TTL, + limit: 2, }); expect(summary.sessions_evicted).toBe(2); q.close(); @@ -153,7 +210,12 @@ test("evicts multiple distinct sessions in one pass", async () => { 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 }); + 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); diff --git a/llm-client/src/outbox/ingest-client.test.ts b/llm-client/src/outbox/ingest-client.test.ts index 2959d49..5ec1c07 100644 --- a/llm-client/src/outbox/ingest-client.test.ts +++ b/llm-client/src/outbox/ingest-client.test.ts @@ -1,6 +1,10 @@ import { test, expect, afterEach } from "bun:test"; import type { Server } from "bun"; -import { postContext, type IngestBody, type PostContextResult } from "./ingest-client"; +import { + postContext, + type IngestBody, + type PostContextResult, +} from "./ingest-client"; let fakeBackend: Server | undefined; let lastReq: { body: string; auth: string | null; url: string } | undefined; @@ -108,11 +112,7 @@ test("503 / 500 / 429 -> retryable", async () => { test("network error -> retryable", async () => { // unreachable port - const r = await postContext( - "http://127.0.0.1:1", - "st_x", - sampleBody(), - ); + const r = await postContext("http://127.0.0.1:1", "st_x", sampleBody()); expect(r.kind).toBe("retryable"); }); diff --git a/llm-client/src/outbox/queue.multiproc.test.ts b/llm-client/src/outbox/queue.multiproc.test.ts index 34c5c53..392fe6e 100644 --- a/llm-client/src/outbox/queue.multiproc.test.ts +++ b/llm-client/src/outbox/queue.multiproc.test.ts @@ -8,7 +8,8 @@ import type { IngestBody } from "./ingest-client"; const tmpDirs: string[] = []; afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); function body(id: string): IngestBody { @@ -25,7 +26,11 @@ function body(id: string): IngestBody { }; } -async function spawnClaimer(scriptPath: string, dbPath: string, limit: number): Promise { +async function spawnClaimer( + scriptPath: string, + dbPath: string, + limit: number, +): Promise { const proc = Bun.spawn(["bun", scriptPath, dbPath, String(limit)], { stdout: "pipe", stderr: "pipe", @@ -69,7 +74,10 @@ q.close(); // 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")}`); + const expected = Array.from( + { length: 200 }, + (_, i) => `r${i.toString().padStart(3, "0")}`, + ); expect(both).toEqual(expected); const setB = new Set(idsB); @@ -111,6 +119,8 @@ q.close(); const all = results.flat(); // Every record claimed exactly once. - expect(all.sort()).toEqual(Array.from({ length: 30 }, (_, i) => `x${i}`).sort()); + 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 index a6c78d4..7ffd931 100644 --- a/llm-client/src/outbox/queue.test.ts +++ b/llm-client/src/outbox/queue.test.ts @@ -30,7 +30,8 @@ function body(id: string): IngestBody { } afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); test("enqueue starts in 'captured' state", () => { @@ -131,8 +132,12 @@ test("opening a legacy queue.db (no session_id column) auto-migrates without thr // 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)") + 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"); @@ -173,7 +178,9 @@ test("pickReady returns records in any active state (captured / ingested / synce q.advance("c", "synced", 0); const ready = q.pickReady(1000, 10); - const byState = Object.fromEntries(ready.map((r) => [r.body.record_id, r.state])); + const byState = Object.fromEntries( + ready.map((r) => [r.body.record_id, r.state]), + ); expect(byState).toEqual({ a: "captured", b: "ingested", c: "synced" }); q.close(); }); diff --git a/llm-client/src/outbox/queue.ts b/llm-client/src/outbox/queue.ts index e00ae6a..1b798e0 100644 --- a/llm-client/src/outbox/queue.ts +++ b/llm-client/src/outbox/queue.ts @@ -81,9 +81,9 @@ export class IngestQueue { * 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 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"); @@ -120,7 +120,10 @@ export class IngestQueue { ORDER BY next_attempt_at ASC, created_at ASC LIMIT ?`, ) - .all(...ACTIVE_STATES, now, limit) as Array<{ body_json: string; state: RecordState }>; + .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, @@ -160,13 +163,10 @@ export class IngestQueue { ) RETURNING body_json, state`, ) - .all( - now, - ...ACTIVE_STATES, - now, - now - staleMs, - limit, - ) as Array<{ body_json: string; state: RecordState }>; + .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, @@ -176,7 +176,11 @@ export class IngestQueue { /** * Advance to the next state on success. Resets attempts + clears error. */ - advance(record_id: string, newState: RecordState, nextAttemptAt: number): void { + advance( + record_id: string, + newState: RecordState, + nextAttemptAt: number, + ): void { this.db .prepare( `UPDATE outbox @@ -263,9 +267,9 @@ export class IngestQueue { LIMIT ?`, ) .all(olderThanCreatedAt, limit) as Array<{ - session_id: string; - record_ids: string; - }>; + session_id: string; + record_ids: string; + }>; return rows.map((r) => ({ session_id: r.session_id, record_ids: r.record_ids.split(SEP), diff --git a/llm-client/src/outbox/relay.test.ts b/llm-client/src/outbox/relay.test.ts index ab53ef9..635742f 100644 --- a/llm-client/src/outbox/relay.test.ts +++ b/llm-client/src/outbox/relay.test.ts @@ -32,7 +32,8 @@ function body(id: string): IngestBody { }; } afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); const advance: StageHandler = async () => ({ kind: "advance" }); @@ -107,7 +108,9 @@ test("terminal -> markTerminal, no retry", async () => { test("handler-throws is treated as retryable", async () => { const q = freshQueue(); q.enqueue(body("r1"), 0); - const boom: StageHandler = async () => { throw new Error("kaboom"); }; + 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"); @@ -119,25 +122,38 @@ test("handler-throws is treated as retryable", async () => { 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 }); + 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("a"), 0); // captured -> ingest q.enqueue(body("b"), 0); - q.advance("b", "ingested", 0); // ingested -> sync + q.advance("b", "ingested", 0); // ingested -> sync q.enqueue(body("c"), 0); q.advance("c", "ingested", 0); - q.advance("c", "synced", 0); // synced -> confirm + 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" }; }, + 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 }); @@ -167,8 +183,8 @@ test("processes the batch in parallel up to concurrency", async () => { 8, ); - expect(peak).toBeGreaterThanOrEqual(2); // actually parallel (not serial) - expect(peak).toBeLessThanOrEqual(4); // bounded by concurrency + 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"); } @@ -194,9 +210,18 @@ test("multi-turn session: independent records sharing one session_id both advanc 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" }; }, + 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 }); diff --git a/llm-client/src/outbox/relay.ts b/llm-client/src/outbox/relay.ts index f72e6e3..f29e855 100644 --- a/llm-client/src/outbox/relay.ts +++ b/llm-client/src/outbox/relay.ts @@ -3,7 +3,12 @@ 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, + 1_000, + 5_000, + 30_000, + 5 * 60_000, + 30 * 60_000, + 60 * 60_000, ]; export function nextBackoff( @@ -26,9 +31,9 @@ export type StageResult = 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 + ingest: StageHandler; // called on state=captured + sync: StageHandler; // called on state=ingested + confirm: StageHandler; // called on state=synced }; export type WorkerOpts = { @@ -46,21 +51,32 @@ export type RunSummary = { terminal: number; }; -function stageFor(state: RecordState, stages: Stages): StageHandler | undefined { +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; + 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; + case "captured": + return "ingested"; + case "ingested": + return "synced"; + case "synced": + return "done"; + default: + return state; } } @@ -83,25 +99,38 @@ export async function runOnce( // 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 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", + }; + } + applyResult( + opts.queue, + slot.body.record_id, + slot.state, + result, + now, + backoff, + ); + if (result.kind === "advance") summary.advance += 1; + else if (result.kind === "retry") summary.retry += 1; + else summary.terminal += 1; } - applyResult(opts.queue, slot.body.record_id, slot.state, result, now, backoff); - 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; @@ -122,7 +151,11 @@ function applyResult( } case "retry": { const attempts = queue.statusOf(recordId)?.attempts ?? 0; - queue.markRetry(recordId, result.reason, now + nextBackoff(attempts, backoff)); + queue.markRetry( + recordId, + result.reason, + now + nextBackoff(attempts, backoff), + ); break; } case "terminal": { diff --git a/llm-client/src/outbox/sync.real.test.ts b/llm-client/src/outbox/sync.real.test.ts index e8ea344..b4258d4 100644 --- a/llm-client/src/outbox/sync.real.test.ts +++ b/llm-client/src/outbox/sync.real.test.ts @@ -35,7 +35,9 @@ test("real Tbox: PUT (with auto-MKCOL) then GET roundtrip", async () => { 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 payload = new TextEncoder().encode( + `{"hello":"tbox","ts":${Date.now()}}`, + ); const result = await syncBlob(put, path, payload); expect(result).toEqual({ kind: "synced" }); @@ -64,12 +66,20 @@ test("real Tbox: second PUT to the same parent reuses cached MKCOL (no extra MKC 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" }); + 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"); + 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}`); @@ -83,7 +93,10 @@ test("real Tbox: wrong password yields a fatal (4xx) sync result", async () => { console.log(`[skip] Tbox not reachable at ${TBOX_URL}`); return; } - const put = makeWebDAVPut(TBOX_URL, { username: "admin", password: "definitely-wrong" }); + 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.ts b/llm-client/src/outbox/sync.ts index f00c44f..642ded6 100644 --- a/llm-client/src/outbox/sync.ts +++ b/llm-client/src/outbox/sync.ts @@ -20,7 +20,10 @@ export async function syncBlob( await put(remotePath, data); return { kind: "synced" }; } catch (e) { - const err = e as Error & { status?: number; response?: { status?: number } }; + 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. @@ -43,7 +46,10 @@ export type WebDAVAuth = { username: string; password: string }; * 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 { +export function makeWebDAVPut( + tboxUrl: string, + auth?: WebDAVAuth, +): WebDAVPutLike { const client: WebDAVClient = createClient( tboxUrl, auth ? { username: auth.username, password: auth.password } : undefined, diff --git a/llm-client/src/recording/capture.test.ts b/llm-client/src/recording/capture.test.ts index 61d9956..f51b398 100644 --- a/llm-client/src/recording/capture.test.ts +++ b/llm-client/src/recording/capture.test.ts @@ -26,14 +26,18 @@ function makeStreamingResponse(chunks: string[], opts: ResponseInit = {}) { } test("captureChat: non-streaming — clientResponse byte-exact, captured request + response bytes returned", async () => { - const requestText = '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'; + 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); + const { clientResponse, captureP } = captureChat( + enc(requestText), + upstreamRes, + ); expect(await clientResponse.text()).toBe(responseText); @@ -41,7 +45,9 @@ test("captureChat: non-streaming — clientResponse byte-exact, captured request 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}$/); + 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 () => { diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index 6db82a1..be601ac 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -103,7 +103,9 @@ test("two consecutive turns of the same session produce strictly growing blob si turn_count: 1, upstream_type: "openai", upstream_status: 200, - request_bytes: enc('{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'), + request_bytes: enc( + '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}', + ), response_bytes: enc('{"choices":[{"message":{"content":"hello"}}]}'), }); const turn2 = buildSessionBlob({ diff --git a/llm-client/src/recording/session.test.ts b/llm-client/src/recording/session.test.ts index 48248dc..f39abba 100644 --- a/llm-client/src/recording/session.test.ts +++ b/llm-client/src/recording/session.test.ts @@ -139,14 +139,22 @@ test("deeply nested key reorder (tool_calls etc.) still matches", () => { role: "assistant", content: null, tool_calls: [ - { id: "1", type: "function", function: { name: "ls", arguments: "{}" } }, + { + id: "1", + type: "function", + function: { name: "ls", arguments: "{}" }, + }, ], } as Message, ]); const second = tr.classify([ { tool_calls: [ - { function: { arguments: "{}", name: "ls" }, type: "function", id: "1" }, + { + function: { arguments: "{}", name: "ls" }, + type: "function", + id: "1", + }, ], content: null, role: "assistant", diff --git a/llm-client/src/util.test.ts b/llm-client/src/util.test.ts index 7fb004e..7a5922a 100644 --- a/llm-client/src/util.test.ts +++ b/llm-client/src/util.test.ts @@ -11,7 +11,8 @@ function freshDir(): string { return d; } afterEach(() => { - for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + for (const d of tmpDirs.splice(0)) + rmSync(d, { recursive: true, force: true }); }); test("writeAtomic writes the full content to the target path", async () => { diff --git a/llm-client/src/util.ts b/llm-client/src/util.ts index 9dba805..e3cb935 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -77,5 +77,3 @@ export class StoppableSleep { this.wake?.(); } } - - From 86aa3befd94641e34da5c4194c4a74ee3bbdb6f7 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 10:40:57 +0800 Subject: [PATCH 25/60] fix(test-scripts): smoke_e2e fake WebDAV needs MKCOL support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-MKCOL was added to the router's sync stage when real Tbox integration landed (commit 85ca416). The fake WebDAV in smoke_e2e.sh wasn't updated: it returned 200 with DAV header for everything non-PUT, but webdav-client's recursive createDirectory expects 201 on MKCOL. So MKCOL hung / errored, sync never advanced, and the record stayed pending. Also bumped sleep 3 → sleep 8 to match the other two smokes (3 stages × ~1s worker poll = 3-5s typical, 8s headroom). Now: 1/3 fake-everything, 2/3 real-Tbox single, 3/3 real-Tbox agent — all PASS. --- test-scripts/smoke_e2e.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test-scripts/smoke_e2e.sh b/test-scripts/smoke_e2e.sh index 095cf61..c2b8841 100755 --- a/test-scripts/smoke_e2e.sh +++ b/test-scripts/smoke_e2e.sh @@ -41,7 +41,8 @@ Bun.serve({ }); " >"$WORK/upstream.log" 2>&1 & -# 3. fake Tbox (WebDAV PUT acceptor) +# 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', @@ -52,6 +53,15 @@ Bun.serve({ 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' } }); } }); @@ -108,8 +118,8 @@ curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"smoke"}]}' echo -# 8. wait for ingest -> sync -> confirm -sleep 3 +# 8. wait for ingest -> sync -> confirm (3 stages × ~1s worker poll) +sleep 8 # 9. inspect db echo "--- ContextRecord rows ---" From bb0d4509d6d7df7863a556ea804826c42d15b57b Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 11:06:34 +0800 Subject: [PATCH 26/60] fix(http): preserve base_url path prefix when forwarding to upstream --- .gitignore | 2 + llm-client/src/http/proxy.test.ts | 41 +++++++ llm-client/src/http/proxy.ts | 14 ++- test-functional/.current | 1 + test-functional/README.md | 61 ++++++++++ test-functional/bringup.sh | 136 ++++++++++++++++++++++ test-functional/inspect.sh | 39 +++++++ test-functional/prompts/01-small-task.md | 37 ++++++ test-functional/prompts/02-multimodal.md | 53 +++++++++ test-functional/prompts/03-skill.md | 53 +++++++++ test-functional/prompts/04-mcp.md | 69 +++++++++++ test-functional/prompts/05-compression.md | 86 ++++++++++++++ test-functional/teardown.sh | 30 +++++ 13 files changed, 618 insertions(+), 4 deletions(-) create mode 120000 test-functional/.current create mode 100644 test-functional/README.md create mode 100755 test-functional/bringup.sh create mode 100755 test-functional/inspect.sh create mode 100644 test-functional/prompts/01-small-task.md create mode 100644 test-functional/prompts/02-multimodal.md create mode 100644 test-functional/prompts/03-skill.md create mode 100644 test-functional/prompts/04-mcp.md create mode 100644 test-functional/prompts/05-compression.md create mode 100755 test-functional/teardown.sh diff --git a/.gitignore b/.gitignore index 021882c..d6f555e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ .DS_Store .*.bun-build +test-functional/runs/ +test-functional/opencode.json diff --git a/llm-client/src/http/proxy.test.ts b/llm-client/src/http/proxy.test.ts index 82b0452..0ef9d97 100644 --- a/llm-client/src/http/proxy.test.ts +++ b/llm-client/src/http/proxy.test.ts @@ -196,3 +196,44 @@ test("proxyChat relays an SSE stream chunk-by-chunk", async () => { 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 recorded: Array<{ url: string }> = []; + const fake = async (url: URL | string) => { + recorded.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(recorded[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 recorded: Array<{ url: string }> = []; + const fake = async (url: URL | string) => { + recorded.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(recorded[0]!.url).toBe("https://api.openai.com/v1/chat/completions"); +}); diff --git a/llm-client/src/http/proxy.ts b/llm-client/src/http/proxy.ts index f0ba215..336e530 100644 --- a/llm-client/src/http/proxy.ts +++ b/llm-client/src/http/proxy.ts @@ -33,10 +33,16 @@ export async function proxyChat( opts: ProxyChatOpts = {}, ): Promise { const inboundUrl = new URL(inbound.url); - const target = new URL( - inboundUrl.pathname + inboundUrl.search, - upstream.base_url, - ); + // We can't use `new URL(absolutePath, base)` because that drops base's + // path component. Concatenate explicitly, then dedupe a `/v1` if the + // user put it in both `base_url` and (per OpenAI convention) we got + // `/v1/chat/completions` inbound. + const base = upstream.base_url.replace(/\/+$/, ""); + let path = inboundUrl.pathname; + if (base.endsWith("/v1") && path.startsWith("/v1/")) { + path = path.slice("/v1".length); + } + const target = new URL(base + path + inboundUrl.search); const forwardedHeaders = new Headers(); inbound.headers.forEach((value, key) => { diff --git a/test-functional/.current b/test-functional/.current new file mode 120000 index 0000000..97dac08 --- /dev/null +++ b/test-functional/.current @@ -0,0 +1 @@ +test-functional/runs/20260514-105704 \ No newline at end of file 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..679d83c --- /dev/null +++ b/test-functional/bringup.sh @@ -0,0 +1,136 @@ +#!/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)" +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/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..9a40583 --- /dev/null +++ b/test-functional/prompts/02-multimodal.md @@ -0,0 +1,53 @@ +# Test 2 — multimodal input/output + +## Goal +Find out how the router represents an **image-bearing request** in the +captured blob. OpenAI-style chat completions encode images as +`content: [{type:"text",...}, {type:"image_url", image_url:{url:"data:image/png;base64,..."}}]`. + +We want to confirm: + +1. The router **does not break** vision requests (passthrough OK). +2. The captured blob preserves the structured `content` array intact — + so a TT can see what image was sent. +3. blob_size grows accordingly (a base64 PNG of even 50KB inflates). + +## Model + +SJTU's GLM is the only model on this gateway with documented vision +support. We'll send to `aimdware/glm`. If GLM rejects it, fall back to +sending to a chat-only model — the router test still measures capture +behaviour, just the upstream will 400. + +## Prompt verbatim + +The literal request bodies live next to this file as: +- `02-multimodal-request.json` (the request POST body, ready to curl) +- `02-multimodal-image.png` (1×1 transparent PNG, base64-inlined in the request) + +## How to run + +```bash +source ./test-functional/bringup.sh +curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ + -H "Authorization: Bearer ignored" \ + -H "Content-Type: application/json" \ + -d @test-functional/prompts/02-multimodal-request.json \ + | tee test-functional/runs/$(basename $WORK)/02-response.json + +sleep 4 +./test-functional/inspect.sh +``` + +## Expected + +- Router accepts and forwards the multimodal payload. +- DB: 1 row, `turn_count=1`. +- Blob: `messages[0].content` is an **array of parts** (text + image_url). + base64 data preserved inline. blob_size > size of a text-only message. +- `verified: true`. + +## What to flag if it breaks + +- If router crashes parsing the array content → bug in our capture/session logic. +- If blob's `messages[0].content` collapses to a string → JSON round-trip lossy. 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/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)" From 5d950ff52b4335fd4a6ac4cbe30429fed0db1c88 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 11:28:29 +0800 Subject: [PATCH 27/60] feat(recording): persist tools + tool_choice --- llm-client/src/recording/session-blob.test.ts | 52 +++++++++++ llm-client/src/recording/session-blob.ts | 15 ++- test-functional/02-image.png | Bin 0 -> 68 bytes test-functional/inspect.sh | 18 +++- test-functional/mcp-sandbox/HELLO.txt | 1 + test-functional/prompts/02-multimodal.md | 88 ++++++++++++------ test-functional/skills/string-tools/SKILL.md | 21 +++++ 7 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 test-functional/02-image.png create mode 100644 test-functional/mcp-sandbox/HELLO.txt create mode 100644 test-functional/skills/string-tools/SKILL.md diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index be601ac..8f48f66 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -126,3 +126,55 @@ test("two consecutive turns of the same session produce strictly growing blob si }); expect(turn2.blob_size).toBeGreaterThan(turn1.blob_size); }); + +test("tools + tool_choice in the request body are preserved on the blob", () => { + const reqText = 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", + 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 obj = JSON.parse(new TextDecoder().decode(r.blob_bytes)); + expect(obj.tools).toHaveLength(1); + expect(obj.tools[0].function.name).toBe("fs_read"); + expect(obj.tool_choice).toBe("auto"); +}); + +test("missing tools / tool_choice → null on the blob (no surprises)", () => { + const r = buildSessionBlob({ + session_id: "x", + course: "C", + 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("{}"), + }); + const obj = JSON.parse(new TextDecoder().decode(r.blob_bytes)); + expect(obj.tools).toBeNull(); + expect(obj.tool_choice).toBeNull(); +}); diff --git a/llm-client/src/recording/session-blob.ts b/llm-client/src/recording/session-blob.ts index 0b30457..31b7c2e 100644 --- a/llm-client/src/recording/session-blob.ts +++ b/llm-client/src/recording/session-blob.ts @@ -36,10 +36,21 @@ export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { let model: string | null = null; let messages: unknown[] = []; + let tools: unknown = null; + let tool_choice: unknown = null; if (parsedReq && typeof parsedReq === "object") { - const req = parsedReq as { model?: unknown; messages?: unknown }; + const req = parsedReq as { + model?: unknown; + messages?: unknown; + tools?: unknown; + tool_choice?: unknown; + }; model = typeof req.model === "string" ? req.model : null; messages = Array.isArray(req.messages) ? req.messages : []; + // Preserve tool definitions + tool_choice so a TT can see what + // capabilities the agent gave the model, not just which it used. + tools = req.tools ?? null; + tool_choice = req.tool_choice ?? null; } const blob = { @@ -52,6 +63,8 @@ export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { upstream_status: input.upstream_status, model, messages, + tools, + tool_choice, latest_response: parsedResp, }; diff --git a/test-functional/02-image.png b/test-functional/02-image.png new file mode 100644 index 0000000000000000000000000000000000000000..909c66db1740b7c1b41eb4db6c414a7ab5bb6a23 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6U5DhwL{ literal 0 HcmV?d00001 diff --git a/test-functional/inspect.sh b/test-functional/inspect.sh index ea35b51..f3c35ae 100755 --- a/test-functional/inspect.sh +++ b/test-functional/inspect.sh @@ -17,11 +17,27 @@ SELECT substr(id,1,8) AS record, turn_count, blob_status, blob_size, - model + ts FROM context_records ORDER BY ts; SQL +# Agent runs spawn many independent sessions (e.g. orchestrator + N +# sub-agents). Cluster blobs by their first user prompt to show which +# logical "thread" each one belongs to. +echo +echo "=== blob thread fingerprint (system + first user, first 80 chars) ===" +D="$WORK/cache/records" +[ -d "$D" ] || exit 0 +for f in "$D"/*.json; do + sid=$(basename "$f" .json | head -c 8) + ts=$(jq -r '.latest_ts' "$f") + msgs=$(jq -r '.messages | length' "$f") + sys0=$(jq -r '.messages[0].content // ""' "$f" 2>/dev/null | head -c 40 | tr '\n' ' ') + usr0=$(jq -r '.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 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/02-multimodal.md b/test-functional/prompts/02-multimodal.md index 9a40583..5322db2 100644 --- a/test-functional/prompts/02-multimodal.md +++ b/test-functional/prompts/02-multimodal.md @@ -1,53 +1,79 @@ # Test 2 — multimodal input/output ## Goal -Find out how the router represents an **image-bearing request** in the -captured blob. OpenAI-style chat completions encode images as -`content: [{type:"text",...}, {type:"image_url", image_url:{url:"data:image/png;base64,..."}}]`. +Drive an image-bearing request **through opencode + Sisyphus through +the router** and observe: -We want to confirm: +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? -1. The router **does not break** vision requests (passthrough OK). -2. The captured blob preserves the structured `content` array intact — - so a TT can see what image was sent. -3. blob_size grows accordingly (a base64 PNG of even 50KB inflates). +## Model choice -## Model +Probed all 5 SJTU models with a base64-inlined PNG: -SJTU's GLM is the only model on this gateway with documented vision -support. We'll send to `aimdware/glm`. If GLM rejects it, fall back to -sending to a chat-only model — the router test still measures capture -behaviour, just the upstream will 400. +| 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 -The literal request bodies live next to this file as: -- `02-multimodal-request.json` (the request POST body, ready to curl) -- `02-multimodal-image.png` (1×1 transparent PNG, base64-inlined in the request) +``` +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/bringup.sh -curl -sS -X POST "http://127.0.0.1:$ROUTER_PORT/v1/chat/completions" \ - -H "Authorization: Bearer ignored" \ - -H "Content-Type: application/json" \ - -d @test-functional/prompts/02-multimodal-request.json \ - | tee test-functional/runs/$(basename $WORK)/02-response.json +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 ``` -## Expected +## 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). -- Router accepts and forwards the multimodal payload. -- DB: 1 row, `turn_count=1`. -- Blob: `messages[0].content` is an **array of parts** (text + image_url). - base64 data preserved inline. blob_size > size of a text-only message. -- `verified: true`. +Save: +- `runs//02-findings.md` — the analysis +- Tag the blob that contained the actual image (if any) for visual + inspection in jbox. -## What to flag if it breaks +## Failure modes to flag -- If router crashes parsing the array content → bug in our capture/session logic. -- If blob's `messages[0].content` collapses to a string → JSON round-trip lossy. +- 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/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. From f6ac65682f9dc3b22aea5feb817fa66195ea8077 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 11:31:31 +0800 Subject: [PATCH 28/60] test(functional): findings from SJTU-upstream run, 5 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hands-on tests against models.sjtu.edu.cn/api/v1 driving the router through opencode (with oh-my-opencode plugins, Sisyphus orchestrator, deepseek-chat / deepseek-reasoner / qwen). 50 captures across 5 scenarios; all `verified=true`. Highlights: test 1 (small task): 17 captures from 1 trivial user prompt — Sisyphus loops + N "What did we do so far?" sub-agents + title gen + summarizers. test 2 (multimodal): router preserves `content: [text|image_url]` end-to-end; opencode's Read tool strips images for unknown-capability custom providers. test 3 (skill): opencode injects `` metadata into the system prompt; agent uses descriptions, rarely loads SKILL.md content. test 4 (MCP): registered fs MCP; agent never reached it — context overflow loop from plugin + tool schemas exceeding deepseek-chat's 65k window. Exposed router gap: `tools` not preserved (fixed in commit 509a670). test 5 (compression): each opencode-internal sub-task (summary, title-gen) inherits the FULL previous assistant output — title gen alone produced a 371KB blob. Net router verdict: works correctly on every capture across all 5 tests. The router is unaffected by upstream/client choices; what varies is how the agent platform chunks its work. Co-Authored-By: Claude Opus 4.7 (1M context) --- test-functional/results/00-records.txt | 52 +++++++++++ test-functional/results/01-findings.md | 111 +++++++++++++++++++++++ test-functional/results/02-findings.md | 87 +++++++++++++++++++ test-functional/results/03-findings.md | 99 +++++++++++++++++++++ test-functional/results/04-findings.md | 116 +++++++++++++++++++++++++ test-functional/results/05-findings.md | 112 ++++++++++++++++++++++++ 6 files changed, 577 insertions(+) create mode 100644 test-functional/results/00-records.txt create mode 100644 test-functional/results/01-findings.md create mode 100644 test-functional/results/02-findings.md create mode 100644 test-functional/results/03-findings.md create mode 100644 test-functional/results/04-findings.md create mode 100644 test-functional/results/05-findings.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. From 811f774e2a5392b104ca6097f22d617a8aa9a62e Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 14:23:32 +0800 Subject: [PATCH 29/60] refactor(recording): blob carries the request verbatim --- llm-client/src/recording/session-blob.test.ts | 160 +++++++++++------- llm-client/src/recording/session-blob.ts | 50 +++--- test-functional/.current | 2 +- test-functional/inspect.sh | 6 +- test-scripts/smoke_e2e_agent.sh | 2 +- 5 files changed, 122 insertions(+), 98 deletions(-) diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index 8f48f66..009d9de 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -13,7 +13,7 @@ const reqText = const respText = '{"id":"chatcmpl-x","choices":[{"message":{"role":"assistant","content":"sure"}}]}'; -test("builds a session-keyed pretty-printed JSON blob with the final turn's messages + response", () => { +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", @@ -27,15 +27,99 @@ test("builds a session-keyed pretty-printed JSON blob with the final turn's mess }); 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" }); - expect(obj.model).toBe("gpt-4o"); - expect(obj.messages).toHaveLength(3); - expect(obj.messages[2]).toEqual({ role: "user", content: "more" }); - expect(obj.latest_response.choices[0].message.content).toBe("sure"); + + // 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 reqText = 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", + 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("{}"), + }); + 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 reqText = 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", + 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("{}"), + }); + 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)", () => { @@ -56,7 +140,7 @@ test("blob is pretty-printed and the hash matches sha256(blob_bytes)", () => { expect(r.blob_size).toBe(r.blob_bytes.byteLength); }); -test("streaming SSE response stays as a raw string in `latest_response`", () => { +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' + @@ -73,11 +157,11 @@ test("streaming SSE response stays as a raw string in `latest_response`", () => response_bytes: enc(sse), }); const obj = JSON.parse(dec(r.blob_bytes)); - expect(typeof obj.latest_response).toBe("string"); - expect(obj.latest_response).toBe(sse); + expect(typeof obj.response).toBe("string"); + expect(obj.response).toBe(sse); }); -test("unparseable request bytes still produce a blob (messages=[], model=null)", () => { +test("unparseable request bytes are kept verbatim under `request` (as a string)", () => { const r = buildSessionBlob({ session_id: "x", course: "Y", @@ -90,8 +174,10 @@ test("unparseable request bytes still produce a blob (messages=[], model=null)", response_bytes: enc(""), }); const obj = JSON.parse(dec(r.blob_bytes)); - expect(obj.messages).toEqual([]); - expect(obj.model).toBeNull(); + // 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", () => { @@ -126,55 +212,3 @@ test("two consecutive turns of the same session produce strictly growing blob si }); expect(turn2.blob_size).toBeGreaterThan(turn1.blob_size); }); - -test("tools + tool_choice in the request body are preserved on the blob", () => { - const reqText = 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", - 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 obj = JSON.parse(new TextDecoder().decode(r.blob_bytes)); - expect(obj.tools).toHaveLength(1); - expect(obj.tools[0].function.name).toBe("fs_read"); - expect(obj.tool_choice).toBe("auto"); -}); - -test("missing tools / tool_choice → null on the blob (no surprises)", () => { - const r = buildSessionBlob({ - session_id: "x", - course: "C", - 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("{}"), - }); - const obj = JSON.parse(new TextDecoder().decode(r.blob_bytes)); - expect(obj.tools).toBeNull(); - expect(obj.tool_choice).toBeNull(); -}); diff --git a/llm-client/src/recording/session-blob.ts b/llm-client/src/recording/session-blob.ts index 31b7c2e..ab87092 100644 --- a/llm-client/src/recording/session-blob.ts +++ b/llm-client/src/recording/session-blob.ts @@ -20,13 +20,25 @@ export type SessionBlobResult = { /** * 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: - * - `messages` is the final turn's request.messages (already contains - * the full history because the agent re-sends everything every turn) - * - `latest_response` is the final turn's response + * on jbox, so it always reflects the latest known state of the session. * - * TT reads this file and the conversation it sees is the conversation - * as of the latest turn — no per-turn snapshots to stitch together. + * 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`, `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); @@ -34,25 +46,6 @@ export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { const parsedReq = tryParseJSON(reqText); const parsedResp = tryParseJSON(respText); - let model: string | null = null; - let messages: unknown[] = []; - let tools: unknown = null; - let tool_choice: unknown = null; - if (parsedReq && typeof parsedReq === "object") { - const req = parsedReq as { - model?: unknown; - messages?: unknown; - tools?: unknown; - tool_choice?: unknown; - }; - model = typeof req.model === "string" ? req.model : null; - messages = Array.isArray(req.messages) ? req.messages : []; - // Preserve tool definitions + tool_choice so a TT can see what - // capabilities the agent gave the model, not just which it used. - tools = req.tools ?? null; - tool_choice = req.tool_choice ?? null; - } - const blob = { session_id: input.session_id, course: input.course, @@ -61,11 +54,8 @@ export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { turn_count: input.turn_count, upstream: { type: input.upstream_type }, upstream_status: input.upstream_status, - model, - messages, - tools, - tool_choice, - latest_response: parsedResp, + request: parsedReq, + response: parsedResp, }; const blob_bytes = new TextEncoder().encode(JSON.stringify(blob, null, 2)); diff --git a/test-functional/.current b/test-functional/.current index 97dac08..7931211 120000 --- a/test-functional/.current +++ b/test-functional/.current @@ -1 +1 @@ -test-functional/runs/20260514-105704 \ No newline at end of file +/Users/zhouzhaojiacheng/personal/courses/ta/router/aimdware/test-functional/runs/20260514-141018 \ No newline at end of file diff --git a/test-functional/inspect.sh b/test-functional/inspect.sh index f3c35ae..e45f0b8 100755 --- a/test-functional/inspect.sh +++ b/test-functional/inspect.sh @@ -32,9 +32,9 @@ D="$WORK/cache/records" for f in "$D"/*.json; do sid=$(basename "$f" .json | head -c 8) ts=$(jq -r '.latest_ts' "$f") - msgs=$(jq -r '.messages | length' "$f") - sys0=$(jq -r '.messages[0].content // ""' "$f" 2>/dev/null | head -c 40 | tr '\n' ' ') - usr0=$(jq -r '.messages | map(select(.role == "user")) | .[0].content // ""' "$f" 2>/dev/null | head -c 40 | tr '\n' ' ') + msgs=$(jq -r '.request.messages | length' "$f") + sys0=$(jq -r '.request.messages[0].content // ""' "$f" 2>/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 diff --git a/test-scripts/smoke_e2e_agent.sh b/test-scripts/smoke_e2e_agent.sh index 8b8d8fe..f326e66 100755 --- a/test-scripts/smoke_e2e_agent.sh +++ b/test-scripts/smoke_e2e_agent.sh @@ -154,7 +154,7 @@ 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['messages'])} messages in final state\") +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"])') From 4cb0f6f002b31c7c20c6bd87394cfd8856b224e1 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 14:27:35 +0800 Subject: [PATCH 30/60] test(recording): pin behaviour under 1-5 MB payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test files exercise the capture / blob / sync paths at realistic agent-platform sizes (1-5 MB), so a future "let's add a size cap" refactor can't silently regress the audit promise. src/recording/large-payload.test.ts (5 new unit tests): - 1 MB user message → blob preserves verbatim, sha256 valid, <500ms - 1 MB+ tools array (200 × 8 KB schemas) → all 200 survive round-trip - 1 MB SSE response → preserved as raw string including [DONE] - SessionTracker prefix-extends a 1 MB conversation across two turns in <1s (catches an O(N²) regression in messagesEqual) - 10 concurrent 1 MB sessions in the LRU → 10th classify <2s src/main-sync.test.ts (+1 integration test): - 2 MB cache file → sync stage reads + uploads byte-identical to fake WebDAV; deterministic sha match. Realistic upper bound: at ~5 MB we're beyond a single 1M-token context window, so the upstream itself would reject before we see it. These tests cover the band of inputs we'd actually receive. Total: llm-client 134 tests pass (was 128). Co-Authored-By: Claude Opus 4.7 (1M context) --- llm-client/src/main-sync.test.ts | 32 ++++ .../src/recording/large-payload.test.ts | 164 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 llm-client/src/recording/large-payload.test.ts diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts index de1be0d..410e6a3 100644 --- a/llm-client/src/main-sync.test.ts +++ b/llm-client/src/main-sync.test.ts @@ -151,3 +151,35 @@ test("sync stage routes WebDAV errors through the result kind", async () => { 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/recording/large-payload.test.ts b/llm-client/src/recording/large-payload.test.ts new file mode 100644 index 0000000..b8b41f9 --- /dev/null +++ b/llm-client/src/recording/large-payload.test.ts @@ -0,0 +1,164 @@ +/** + * 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", + 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", + 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", + 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); +}); From 0c10889af0db746c447fdb5bed372da2f8970985 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Thu, 14 May 2026 14:40:48 +0800 Subject: [PATCH 31/60] feat(backend): add assignment dimension --- ..._0003_add_assignment_to_context_records.py | 50 +++++++++++++++++++ backend/src/aimdware_backend/admin_cli.py | 3 ++ backend/src/aimdware_backend/models.py | 5 ++ backend/src/aimdware_backend/routes/ingest.py | 3 ++ backend/tests/test_admin.py | 6 ++- backend/tests/test_ingest.py | 3 +- backend/tests/test_models.py | 5 +- llm-client/aimdware.example.yaml | 6 +++ llm-client/src/config.test.ts | 18 +++++-- llm-client/src/config.ts | 5 +- llm-client/src/main-sync.test.ts | 1 + llm-client/src/main.test.ts | 2 + llm-client/src/main.ts | 2 + llm-client/src/outbox/eviction.test.ts | 1 + llm-client/src/outbox/ingest-client.test.ts | 1 + llm-client/src/outbox/ingest-client.ts | 1 + llm-client/src/outbox/queue.multiproc.test.ts | 1 + llm-client/src/outbox/queue.test.ts | 1 + llm-client/src/outbox/relay.test.ts | 1 + .../src/recording/large-payload.test.ts | 3 ++ llm-client/src/recording/session-blob.test.ts | 8 +++ llm-client/src/recording/session-blob.ts | 2 + test-functional/bringup.sh | 1 + test-scripts/smoke_e2e.sh | 1 + test-scripts/smoke_e2e_agent.sh | 17 ++++--- test-scripts/smoke_e2e_real_tbox.sh | 1 + 26 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/b984da6ac5c5_0003_add_assignment_to_context_records.py 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/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index 6aca18c..ba10cf8 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -252,6 +252,8 @@ def _cmd_record_list(session: Session, args: argparse.Namespace) -> None: 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() @@ -262,6 +264,7 @@ def _cmd_record_list(session: Session, args: argparse.Namespace) -> None: "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, diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py index a8cb0f1..7eca5a0 100644 --- a/backend/src/aimdware_backend/models.py +++ b/backend/src/aimdware_backend/models.py @@ -98,6 +98,11 @@ class ContextRecord(SQLModel, table=True): 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. diff --git a/backend/src/aimdware_backend/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index f84da3f..3ad9ad6 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -34,6 +34,7 @@ class IngestContextBody(BaseModel): session_id: UUID turn_count: int = Field(ge=1, default=1) course_code: str + assignment: str # free-form, course-scoped; TT decides the namespace blob_hash: str # hex-encoded sha256 blob_uri: str blob_size: int = Field(ge=0) @@ -92,6 +93,7 @@ def post_context( and existing.blob_size == body.blob_size and existing.user_id == user.id and existing.course_id == course.id + and existing.assignment == body.assignment ) if not same: raise HTTPException( @@ -105,6 +107,7 @@ def post_context( 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, diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index bec0647..bb4c989 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -46,11 +46,12 @@ def _seed_record(session: Session, payload: bytes) -> ContextRecord: 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/sample.json", + blob_uri="aimdware/ECE4721J/hw1/sample.json", blob_hash=hashlib.sha256(payload).digest(), blob_size=len(payload), ) @@ -203,11 +204,12 @@ def _seed_session(session: Session, *, turns: list[bytes]) -> tuple[list[Context 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/{sess_id}.json", + blob_uri=f"aimdware/ECE4721J/hw1/{sess_id}.json", blob_hash=hashlib.sha256(payload).digest(), blob_size=len(payload), ) diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py index 218f095..fb6150d 100644 --- a/backend/tests/test_ingest.py +++ b/backend/tests/test_ingest.py @@ -52,8 +52,9 @@ def _body(course_code: str = "ECE4721J", **overrides) -> dict: "session_id": str(uuid4()), "turn_count": 1, "course_code": course_code, + "assignment": "hw1", "blob_hash": "ab" * 32, - "blob_uri": "aimdware/ECE4721J/abc.json", + "blob_uri": "aimdware/ECE4721J/hw1/abc.json", "blob_size": 1234, "model": "gpt-4o-mini", "prompt_tokens": 10, diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 2955c1e..2eaecba 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -99,6 +99,7 @@ def test_context_record_persists_with_blob_metadata(session: Session) -> None: rec = ContextRecord( user_id=user.id, course_id=course.id, + assignment="hw1", session_id=uuid4(), turn_count=1, model="gpt-4o-mini", @@ -106,7 +107,7 @@ def test_context_record_persists_with_blob_metadata(session: Session) -> None: completion_tokens=20, router_version="0.0.0", client_meta={"agent": "cline"}, - blob_uri="aimdware/C1/abc.json", + blob_uri="aimdware/C1/hw1/abc.json", blob_hash=b"\x00" * 32, blob_size=12345, ) @@ -130,6 +131,7 @@ def test_context_record_id_is_pk_unique(session: Session) -> None: base = dict( user_id=user.id, course_id=course.id, + assignment="hw1", session_id=uuid4(), turn_count=1, model="gpt", @@ -156,6 +158,7 @@ def test_context_record_unique_session_turn(session: Session) -> None: base = dict( user_id=user.id, course_id=course.id, + assignment="hw1", session_id=sess_id, turn_count=1, model="gpt", diff --git a/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml index d6eb7f3..af80bb8 100644 --- a/llm-client/aimdware.example.yaml +++ b/llm-client/aimdware.example.yaml @@ -16,6 +16,12 @@ student_token: REPLACE_ME # Course code, must match what the TT enrolled you in. course: REPLACE_ME +# Assignment label inside that course. Free-form, but TT-decreed — +# whatever string the TT told you to use for this hw / lab / exam. +# 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 diff --git a/llm-client/src/config.test.ts b/llm-client/src/config.test.ts index 9851cd0..1f5ceda 100644 --- a/llm-client/src/config.test.ts +++ b/llm-client/src/config.test.ts @@ -5,6 +5,7 @@ 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 @@ -21,6 +22,7 @@ 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 @@ -30,26 +32,30 @@ backend_url: https://b.example 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"); + 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\nupstream:\n api_key: k\nbackend_url: u`, + `course: X\nassignment: hw1\nupstream:\n api_key: k\nbackend_url: u`, ], [ "missing course", - `student_token: t\nupstream:\n api_key: k\nbackend_url: u`, + `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\nupstream: {}\nbackend_url: u`, + `student_token: t\ncourse: X\nassignment: hw1\nupstream: {}\nbackend_url: u`, ], [ "missing backend_url", - `student_token: t\ncourse: X\nupstream:\n api_key: k`, + `student_token: t\ncourse: X\nassignment: hw1\nupstream:\n api_key: k`, ], ]; for (const [label, yaml] of cases) { @@ -61,6 +67,7 @@ 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 @@ -73,6 +80,7 @@ test("loadConfig parses an explicit upstream.type", () => { const yaml = ` student_token: st_x course: ECE4721J +assignment: hw1 upstream: type: openai api_key: sk-x diff --git a/llm-client/src/config.ts b/llm-client/src/config.ts index fd4dbed..772c2d1 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -10,6 +10,7 @@ const UpstreamSchema = z.object({ const RawConfigSchema = z.object({ student_token: z.string().min(1, "student_token is required"), course: z.string().min(1, "course is required"), + assignment: z.string().min(1, "assignment is required"), upstream: UpstreamSchema, port: z.number().int().positive().default(12345), local_cache_dir: z.string().default("~/.cache/aimdware"), @@ -29,6 +30,8 @@ export function loadConfig(yamlText: string): Config { const parsed = RawConfigSchema.parse(raw); return { ...parsed, - jbox_remote_path: parsed.jbox_remote_path ?? `aimdware/${parsed.course}`, + jbox_remote_path: + parsed.jbox_remote_path ?? + `aimdware/${parsed.course}/${parsed.assignment}`, }; } diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts index 410e6a3..08c9d12 100644 --- a/llm-client/src/main-sync.test.ts +++ b/llm-client/src/main-sync.test.ts @@ -51,6 +51,7 @@ function body( session_id, turn_count, course_code: "X", + assignment: "hw1", blob_hash: "abc", blob_uri: `aimdware/X/${session_id}.json`, blob_size: 0, diff --git a/llm-client/src/main.test.ts b/llm-client/src/main.test.ts index b653908..edef7f3 100644 --- a/llm-client/src/main.test.ts +++ b/llm-client/src/main.test.ts @@ -59,6 +59,7 @@ test("main: serves /healthz and proxies a chat completion end-to-end", async () ` student_token: st_test course: ECE4721J +assignment: hw1 upstream: base_url: http://127.0.0.1:${fakeUpstream.port} api_key: sk-test @@ -117,6 +118,7 @@ test("main: never prints plaintext student_token or upstream api_key", async () ` student_token: ${STUDENT} course: ECE4721J +assignment: hw1 upstream: base_url: http://127.0.0.1:${fakeUpstream.port} api_key: ${APIKEY} diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 991596e..71b72f1 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -179,6 +179,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); 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, @@ -204,6 +205,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); 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, diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts index fe48a5d..35a3c52 100644 --- a/llm-client/src/outbox/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -28,6 +28,7 @@ function body( session_id, turn_count, course_code: "ECE4721J", + assignment: "hw1", blob_hash: "h", blob_uri: `jbox://x/${session_id}.json`, blob_size: 1, diff --git a/llm-client/src/outbox/ingest-client.test.ts b/llm-client/src/outbox/ingest-client.test.ts index 5ec1c07..6505eee 100644 --- a/llm-client/src/outbox/ingest-client.test.ts +++ b/llm-client/src/outbox/ingest-client.test.ts @@ -40,6 +40,7 @@ function sampleBody(): IngestBody { session_id: "22222222-2222-2222-2222-222222222222", turn_count: 1, course_code: "ECE4721J", + assignment: "hw1", blob_hash: "deadbeef", blob_uri: "jbox://zhangsan/aimdware/ECE4721J/x.json", blob_size: 123, diff --git a/llm-client/src/outbox/ingest-client.ts b/llm-client/src/outbox/ingest-client.ts index 7f83653..33d1b6c 100644 --- a/llm-client/src/outbox/ingest-client.ts +++ b/llm-client/src/outbox/ingest-client.ts @@ -6,6 +6,7 @@ export type IngestBody = { session_id: string; turn_count: number; course_code: string; + assignment: string; blob_hash: string; blob_uri: string; blob_size: number; diff --git a/llm-client/src/outbox/queue.multiproc.test.ts b/llm-client/src/outbox/queue.multiproc.test.ts index 392fe6e..b286209 100644 --- a/llm-client/src/outbox/queue.multiproc.test.ts +++ b/llm-client/src/outbox/queue.multiproc.test.ts @@ -18,6 +18,7 @@ function body(id: string): IngestBody { session_id: `sess-${id}`, turn_count: 1, course_code: "ECE4721J", + assignment: "hw1", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, blob_size: 1, diff --git a/llm-client/src/outbox/queue.test.ts b/llm-client/src/outbox/queue.test.ts index 7ffd931..6c4195b 100644 --- a/llm-client/src/outbox/queue.test.ts +++ b/llm-client/src/outbox/queue.test.ts @@ -21,6 +21,7 @@ function body(id: string): IngestBody { session_id: `sess-${id}`, turn_count: 1, course_code: "ECE4721J", + assignment: "hw1", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, blob_size: 1, diff --git a/llm-client/src/outbox/relay.test.ts b/llm-client/src/outbox/relay.test.ts index 635742f..3790cfd 100644 --- a/llm-client/src/outbox/relay.test.ts +++ b/llm-client/src/outbox/relay.test.ts @@ -24,6 +24,7 @@ function body(id: string): IngestBody { session_id: `sess-${id}`, turn_count: 1, course_code: "ECE4721J", + assignment: "hw1", blob_hash: "h", blob_uri: `jbox://x/${id}.json`, blob_size: 1, diff --git a/llm-client/src/recording/large-payload.test.ts b/llm-client/src/recording/large-payload.test.ts index b8b41f9..e77635c 100644 --- a/llm-client/src/recording/large-payload.test.ts +++ b/llm-client/src/recording/large-payload.test.ts @@ -33,6 +33,7 @@ test("buildSessionBlob handles a 1 MB user message verbatim", () => { const r = buildSessionBlob({ session_id: "x", course: "C", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -73,6 +74,7 @@ test("buildSessionBlob handles a 1 MB tools array verbatim", () => { const r = buildSessionBlob({ session_id: "x", course: "C", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -102,6 +104,7 @@ test("buildSessionBlob handles a 1 MB SSE-streamed response (kept as raw string) const r = buildSessionBlob({ session_id: "x", course: "C", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index 009d9de..ebf67f6 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -17,6 +17,7 @@ test("router metadata is at blob root; the whole parsed request lives under `req 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, @@ -65,6 +66,7 @@ test("ANY field on the request body is preserved verbatim (future-proof)", () => const r = buildSessionBlob({ session_id: "x", course: "C", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -108,6 +110,7 @@ test("tools + tool_choice round-trip through `request`", () => { const r = buildSessionBlob({ session_id: "x", course: "C", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -126,6 +129,7 @@ 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, @@ -148,6 +152,7 @@ test("streaming SSE response stays as a raw string under `response`", () => { const r = buildSessionBlob({ session_id: "x", course: "Y", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -165,6 +170,7 @@ 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, @@ -184,6 +190,7 @@ test("two consecutive turns of the same session produce strictly growing blob si const turn1 = buildSessionBlob({ session_id: "s1", course: "X", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(0), turn_count: 1, @@ -197,6 +204,7 @@ test("two consecutive turns of the same session produce strictly growing blob si const turn2 = buildSessionBlob({ session_id: "s1", course: "X", + assignment: "hw1", started_at: new Date(0), latest_ts: new Date(1), turn_count: 2, diff --git a/llm-client/src/recording/session-blob.ts b/llm-client/src/recording/session-blob.ts index ab87092..121cb3a 100644 --- a/llm-client/src/recording/session-blob.ts +++ b/llm-client/src/recording/session-blob.ts @@ -3,6 +3,7 @@ import { decodeBytes, tryParseJSON } from "./capture"; export type SessionBlobInput = { session_id: string; course: string; + assignment: string; started_at: Date; latest_ts: Date; turn_count: number; @@ -49,6 +50,7 @@ export function buildSessionBlob(input: SessionBlobInput): SessionBlobResult { 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, diff --git a/test-functional/bringup.sh b/test-functional/bringup.sh index 679d83c..0bab67c 100755 --- a/test-functional/bringup.sh +++ b/test-functional/bringup.sh @@ -68,6 +68,7 @@ echo "--- token issued: ${TOKEN:0:8}… ---" cat >"$WORK/aimdware.yaml" <"$WORK/aimdware.yaml" <//; 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" @@ -36,10 +40,10 @@ 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" - echo " inspect: curl -u $TBOX_USER:$TBOX_PASS $TBOX_URL/$TBOX_SUBDIR/" + 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_SUBDIR" >/dev/null 2>&1 || true + curl -sS -u "$TBOX_USER:$TBOX_PASS" -X DELETE "$TBOX_URL/$TBOX_COURSE_DIR" >/dev/null 2>&1 || true rm -rf "$WORK" fi } @@ -87,6 +91,7 @@ TOKEN="$(cd "$REPO_ROOT/backend" && E2E_COURSE="$COURSE" \ cat >"$WORK/aimdware.yaml" <[^<]+\.json' | wc -l | tr -d ' ') -echo " json files on jbox under /$TBOX_SUBDIR/: $COUNT" +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 diff --git a/test-scripts/smoke_e2e_real_tbox.sh b/test-scripts/smoke_e2e_real_tbox.sh index 24578e5..8ff9c97 100755 --- a/test-scripts/smoke_e2e_real_tbox.sh +++ b/test-scripts/smoke_e2e_real_tbox.sh @@ -110,6 +110,7 @@ echo "--- seeded token: ${TOKEN:0:8}… course: $COURSE ---" cat >"$WORK/aimdware.yaml" < Date: Sat, 16 May 2026 15:10:34 +0800 Subject: [PATCH 32/60] docs(wiki): align with current implementation + add design-notes --- wiki/admin-script.md | 2 +- wiki/architecture.md | 156 ++++++++++--------- wiki/backend.md | 298 +++++++++++++++++++----------------- wiki/design-notes.md | 184 +++++++++++++++++++++++ wiki/llm-client.md | 351 +++++++++++++++++++++++++++++++------------ wiki/threat-model.md | 5 + 6 files changed, 693 insertions(+), 303 deletions(-) create mode 100644 wiki/design-notes.md diff --git a/wiki/admin-script.md b/wiki/admin-script.md index 286e02c..8674cc7 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -33,7 +33,7 @@ aimdware-admin token issue --user alice # revokes any prior active to 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] [--status pending|uploaded|...] [--limit N] +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 ``` diff --git a/wiki/architecture.md b/wiki/architecture.md index ebcd886..d6affcc 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -2,96 +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 - +-----------------------+ - | - v - +--------------------------+ +-----------------+ - | Backend (ingest only) |<---| Client router | - | metadata + hash only | | student's | - +--------------------------+ | localhost | - +--+--------+-----+ - | | - blob | | chat - (JSON)| | completion - v v - +----------------------+ +--------------+ - | 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 | -| ------------------- | ------- | ----------------------------- | -| Student 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. -One student token per student; the course context is sent in each -ingest request body (`course_code`). 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 they -are enrolled in; LLM access is unaffected. +## Storage split -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. +- **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` in `{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. +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 - x 100 active students x 50 req/day x 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 1d198b2..d1b668d 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -1,169 +1,195 @@ # Backend -- Python 3.12 + FastAPI + SQLModel + Alembic + PostgreSQL -- Auth: student-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 +## Surfaces -Caller: student router. Auth: student token in `Authorization: Bearer st_...`. -Write-only — no endpoint returns content. - -| Method | Path | Function | -| ------ | -------------------------------------- | -------- | -| `GET` | `/ingest/health` | Unauthenticated liveness probe. | -| `POST` | `/ingest/context` | Record one entry. Body: `{ record_id, course_code, blob_hash, blob_uri, blob_size, model, prompt_tokens, completion_tokens, ts, router_version, client_meta }`. Backend resolves `student_id` from the token, resolves `course_id` from `course_code`, verifies `Enrollment(student, course, role=student)` exists, 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`. Returns `403` if the student is not enrolled in the named course. | -| `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 - - -class Role(str, Enum): - student = "student" - admin = "admin" # TT — scoped to the courses where this row exists - - -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) +``` +/ingest/* student router → here. metadata + hash only, no blob bytes. +/admin/* TT tooling → here. read-only audit. shared-secret auth. +``` +### Ingest API -class User(SQLModel, table=True): - __tablename__ = "users" +Caller: student router. Auth: student token in `Authorization: Bearer st_...`. - 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) +| 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 Course(SQLModel, table=True): - __tablename__ = "courses" +## Data model - 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) +SQLModel-declared, Alembic-migrated. Schema diagram: +``` +users(id, jaccount UNIQUE, email UNIQUE, display_name, 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) +``` -class Enrollment(SQLModel, table=True): - __tablename__ = "enrollments" +### Why `session_id` is indexed but not foreign-keyed - 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) +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. +### Why `assignment` is a string, not a FK -class StudentToken(SQLModel, table=True): - __tablename__ = "student_tokens" +Assignments are TT-decreed free-form labels (homework slug, lab number, +exam name). 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. - 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=datetime.utcnow) - revoked_at: Optional[datetime] = None +### BlobStatus semantics under multi-turn - # Partial unique index on (user_id) WHERE revoked_at IS NULL - # is created in the Alembic migration to enforce one active token per - # student. Course context is supplied per-request in /ingest/context. - __table_args__ = (Index("ix_student_tokens_user", "user_id"),) +``` +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 +``` +**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. -class ContextRecord(SQLModel, table=True): - __tablename__ = "context_records" +## Token validation - 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)) +Plaintext lives only on the student's machine. Backend stores +`sha256(token)`. Per request: - 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 +``` +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 ``` -Notes: - -- One `StudentToken` per student. Rotation inserts a new row and sets - `revoked_at` on the old; the partial unique index enforces "at most - one active token per student". -- Course context per ingest is supplied via `course_code` in the request - body. Backend resolves the course and verifies the student is enrolled - in it (`role = student`); otherwise rejects with 403. -- `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. +Implementation: -## Token validation +- 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. + +## Migrations -The plaintext token never lives on the backend. Each ingest request: +Production schema is owned by Alembic, not `SQLModel.metadata.create_all`: ``` -1. plaintext = request.headers["Authorization"].removeprefix("Bearer ") -2. hash = sha256(plaintext) -3. row = SELECT user_id FROM student_tokens - WHERE token_hash = hash AND revoked_at IS NULL -4. if not row: return 401 -5. inject user_id into request.context +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 ``` -Implementation notes: +Apply: `AIMDWARE_DATABASE_URL=... uv run alembic upgrade head`. -- The DB equality on `token_hash` (a fixed-length `bytea`) is constant-time - for our purposes; no need for `hmac.compare_digest` after the lookup - succeeds (the row matched by definition). -- `Authorization` is added to the logger's redact set, so plaintext - never appears in stdout / file logs. -- We use plain sha256, not argon2 / bcrypt. Tokens are 256-bit random - secrets — search space is 2²⁵⁶, rainbow tables don't apply. Slow - hashing exists to penalize weak passwords; it's not needed here. +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 +## Security recap - **Token → student attribution.** The token identifies the student; - the router cannot impersonate someone else by tampering with the body. -- **Course is verified per request.** Backend rejects ingest for any - course the student is not enrolled in. -- **Write-only ingest.** No HTTP read surface, so a stolen student - token writes at most fake records for that student's enrolled courses. -- **No content on the backend.** Postgres holds metadata + hash + URI. - A full DB compromise yields no student work. -- **DB compromise leaks no usable token.** Only `sha256(token)` is - stored. Rotation is the response to a suspected leak — see - [admin-script.md](admin-script.md). -- **Constant-time compare** on student-token validation; tokens never - in logs. + 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..2534f0c --- /dev/null +++ b/wiki/design-notes.md @@ -0,0 +1,184 @@ +# 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. +- **Codex / Copilot OAuth upstreams.** Only OpenAI-key style v1. +- **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 7b452fd..d3bc597 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -1,49 +1,72 @@ # 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 OpenAI-compatible upstream. Three things happen per chat 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 student_token: st_... # one per student; TT hands it directly course: ECE4721J # course code; sent with every ingest call +assignment: hw1 # free-form label within the course; same payload-path level upstream: - base_url: https://api.openai.com # default; overridable + type: openai # default; only openai supported in v1 + base_url: https://models.sjtu.edu.cn/api/v1 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 +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: full override. Default = aimdware// +# jbox_remote_path: aimdware/ECE4721J/hw1 ``` -`student_token` is one credential per student; `course` selects which -course context this router instance reports to. A student enrolled in -multiple courses runs one router instance per course (different `port` -and `course`), or a single instance with multi-course config (post-v1). +### 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 | call the student's chosen LLM 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, all three secrets are compromised. The +backend can mint a new `student_token` via `aimdware-admin token issue`; +LLM provider and WebDAV credentials are the student's own to rotate. + +### Why "tbox_*" instead of "webdav_*" -The router holds no jbox credential — auth lives inside Tbox. +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. -### Token storage and rotation +## What lands on disk -The `student_token` plaintext is **the only long-lived copy** of the -credential. The backend stores only `sha256(token)`. Implications: +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 +``` -- `config.json` is written with mode `600`; the router never logs the - plaintext (`student_token` is on the redact list). -- If the laptop / config file is compromised, treat the token as leaked - and ask the TT to run `aimdware-admin token rotate --user `. - This invalidates the leaked token at the next backend request and - prints a new plaintext to paste into config. -- If the student loses the token, the same rotate flow is the only - recovery — we cannot read back the old one from the backend. +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 @@ -51,71 +74,205 @@ 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 reclaimable when **every** record sharing +that `session_id` has reached `state = done` AND the latest of them +was created more than `ttlMs` ago (default 7 days). One delete per +session; all member records get `cache_evicted = 1`. + +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/threat-model.md b/wiki/threat-model.md index 6046797..e75be22 100644 --- a/wiki/threat-model.md +++ b/wiki/threat-model.md @@ -43,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). From 661e294ae6c2f3813875ec932b83afce60130525 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 16:43:36 +0800 Subject: [PATCH 33/60] fix(admin_cli): register --assignment on `record list` parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler `_cmd_record_list` was already filtering on args.assignment since the assignment-dimension commit (6de0a61), but the argparse `add_argument("--assignment", ...)` had been dropped in a rebase along the way — every `record list` call crashed with AttributeError. Caught while walking through a live demo. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/aimdware_backend/admin_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index ba10cf8..b1de89d 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -361,6 +361,7 @@ def build_parser() -> argparse.ArgumentParser: 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, From 88f9439ae57eb3fbac71a3720c77101b55748fe6 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 19:31:19 +0800 Subject: [PATCH 34/60] fix(aimdware): harden ingest validation and cache cleanup --- backend/src/aimdware_backend/admin_cli.py | 39 ++++++- backend/src/aimdware_backend/routes/ingest.py | 76 ++++++++++--- backend/tests/test_admin_cli.py | 41 ++++++- backend/tests/test_ingest.py | 100 +++++++++++++++++- llm-client/aimdware.example.yaml | 4 +- llm-client/src/config.test.ts | 31 ++++++ llm-client/src/config.ts | 21 +++- llm-client/src/http/handler.test.ts | 49 +++++++++ llm-client/src/http/handler.ts | 15 ++- llm-client/src/http/proxy.test.ts | 16 +-- llm-client/src/main-sync.test.ts | 6 +- llm-client/src/outbox/eviction.test.ts | 69 +++++++++++- llm-client/src/outbox/eviction.ts | 11 +- llm-client/src/outbox/ingest-client.test.ts | 6 +- llm-client/src/outbox/queue.ts | 6 +- llm-client/src/recording/session-blob.test.ts | 8 +- wiki/backend.md | 4 +- wiki/llm-client.md | 13 +-- 18 files changed, 454 insertions(+), 61 deletions(-) diff --git a/backend/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index b1de89d..8320bf7 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -25,7 +25,8 @@ from typing import Any from uuid import UUID -from sqlmodel import Session, SQLModel, select +from sqlalchemy import text +from sqlmodel import Session, select from aimdware_backend.db import get_engine from aimdware_backend.models import ( @@ -380,7 +381,19 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) engine = get_engine() - SQLModel.metadata.create_all(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): asyncio.run(args.func(session, args)) @@ -389,5 +402,27 @@ def main(argv: list[str] | None = None) -> int: return 0 +CURRENT_SCHEMA_REVISION = "b984da6ac5c5" + + +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/routes/ingest.py b/backend/src/aimdware_backend/routes/ingest.py index 3ad9ad6..a9616f3 100644 --- a/backend/src/aimdware_backend/routes/ingest.py +++ b/backend/src/aimdware_backend/routes/ingest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Annotated, Any from uuid import UUID @@ -34,9 +34,21 @@ class IngestContextBody(BaseModel): session_id: UUID turn_count: int = Field(ge=1, default=1) course_code: str - assignment: str # free-form, course-scoped; TT decides the namespace - blob_hash: str # hex-encoded sha256 - blob_uri: 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 @@ -49,6 +61,19 @@ class IngestContextBody(BaseModel): 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: @@ -83,22 +108,45 @@ def post_context( 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: - same = ( - existing.blob_hash == digest - and existing.blob_uri == body.blob_uri - and existing.blob_size == body.blob_size - and existing.user_id == user.id - and existing.course_id == course.id - and existing.assignment == body.assignment - ) - if not same: + # 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 with different content", + 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"} diff --git a/backend/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py index 7dbb0cf..96eecf0 100644 --- a/backend/tests/test_admin_cli.py +++ b/backend/tests/test_admin_cli.py @@ -4,9 +4,12 @@ import hashlib -from sqlmodel import Session, select +import pytest +from sqlmodel import Session, SQLModel, create_engine, select +from aimdware_backend import settings from aimdware_backend.admin_cli import ( + _schema_exists, course_create, enroll, token_issue, @@ -92,3 +95,39 @@ def test_token_revoke_by_prefix(session: Session) -> 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 diff --git a/backend/tests/test_ingest.py b/backend/tests/test_ingest.py index fb6150d..c687ea6 100644 --- a/backend/tests/test_ingest.py +++ b/backend/tests/test_ingest.py @@ -47,14 +47,15 @@ def enrolled_student(session: Session) -> tuple[str, User, Course]: def _body(course_code: str = "ECE4721J", **overrides) -> dict: + sess = str(uuid4()) base = { "record_id": str(uuid4()), - "session_id": str(uuid4()), + "session_id": sess, "turn_count": 1, "course_code": course_code, "assignment": "hw1", "blob_hash": "ab" * 32, - "blob_uri": "aimdware/ECE4721J/hw1/abc.json", + "blob_uri": f"aimdware/{course_code}/hw1/{sess}.json", "blob_size": 1234, "model": "gpt-4o-mini", "prompt_tokens": 10, @@ -241,12 +242,107 @@ def test_post_context_duplicate_session_turn_returns_409( 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/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml index af80bb8..64fea7c 100644 --- a/llm-client/aimdware.example.yaml +++ b/llm-client/aimdware.example.yaml @@ -16,8 +16,8 @@ student_token: REPLACE_ME # Course code, must match what the TT enrolled you in. course: REPLACE_ME -# Assignment label inside that course. Free-form, but TT-decreed — -# whatever string the TT told you to use for this hw / lab / exam. +# 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 diff --git a/llm-client/src/config.test.ts b/llm-client/src/config.test.ts index 1f5ceda..d878c18 100644 --- a/llm-client/src/config.test.ts +++ b/llm-client/src/config.test.ts @@ -63,6 +63,37 @@ test("loadConfig rejects missing required fields", () => { } }); +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 diff --git a/llm-client/src/config.ts b/llm-client/src/config.ts index 772c2d1..6f0266b 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -1,6 +1,12 @@ 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"]).default("openai"), base_url: z.string().default("https://api.openai.com"), @@ -9,8 +15,8 @@ const UpstreamSchema = z.object({ const RawConfigSchema = z.object({ student_token: z.string().min(1, "student_token is required"), - course: z.string().min(1, "course is required"), - assignment: z.string().min(1, "assignment is required"), + course: SlugSchema, + assignment: SlugSchema, upstream: UpstreamSchema, port: z.number().int().positive().default(12345), local_cache_dir: z.string().default("~/.cache/aimdware"), @@ -28,10 +34,15 @@ export type Config = z.infer & { 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 ?? - `aimdware/${parsed.course}/${parsed.assignment}`, + 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 index 398c27b..69546af 100644 --- a/llm-client/src/http/handler.test.ts +++ b/llm-client/src/http/handler.test.ts @@ -156,3 +156,52 @@ test("/v1/chat/completions streams response while still capturing", async () => 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 index b4d53db..47a1b3f 100644 --- a/llm-client/src/http/handler.ts +++ b/llm-client/src/http/handler.ts @@ -3,7 +3,7 @@ import { proxyChat, type FetchLike, type UpstreamConfig } from "./proxy"; export type HandlerOpts = { upstream: UpstreamConfig; - onCapture?: (result: CaptureResult) => void; + onCapture?: (result: CaptureResult) => void | Promise; fetchImpl?: FetchLike; }; @@ -43,8 +43,17 @@ async function handleChat(req: Request, opts: HandlerOpts): Promise { const { clientResponse, captureP } = captureChat(requestBytes, upstreamRes); captureP.then( - (result) => { - opts.onCapture?.(result); + 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); diff --git a/llm-client/src/http/proxy.test.ts b/llm-client/src/http/proxy.test.ts index 0ef9d97..869465e 100644 --- a/llm-client/src/http/proxy.test.ts +++ b/llm-client/src/http/proxy.test.ts @@ -200,9 +200,9 @@ test("proxyChat relays an SSE stream chunk-by-chunk", async () => { 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 recorded: Array<{ url: string }> = []; + const prefixCalls: Array<{ url: string }> = []; const fake = async (url: URL | string) => { - recorded.push({ url: typeof url === "string" ? url : url.toString() }); + prefixCalls.push({ url: typeof url === "string" ? url : url.toString() }); return new Response('{"ok":true}', { status: 200 }); }; @@ -216,13 +216,15 @@ test("proxyChat preserves base_url path prefix (SJTU-style /api/v1)", async () = { fetchImpl: fake as FetchLike }, ); - expect(recorded[0]!.url).toBe("https://example.com/api/v1/chat/completions"); + 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 recorded: Array<{ url: string }> = []; + const dedupeCalls: Array<{ url: string }> = []; const fake = async (url: URL | string) => { - recorded.push({ url: typeof url === "string" ? url : url.toString() }); + dedupeCalls.push({ url: typeof url === "string" ? url : url.toString() }); return new Response("{}", { status: 200 }); }; @@ -235,5 +237,7 @@ test("proxyChat does not double-prefix /v1 when base_url already ends with /v1", { fetchImpl: fake as FetchLike }, ); - expect(recorded[0]!.url).toBe("https://api.openai.com/v1/chat/completions"); + expect(dedupeCalls[0]!.url).toBe( + "https://api.openai.com/v1/chat/completions", + ); }); diff --git a/llm-client/src/main-sync.test.ts b/llm-client/src/main-sync.test.ts index 08c9d12..1e4bcd0 100644 --- a/llm-client/src/main-sync.test.ts +++ b/llm-client/src/main-sync.test.ts @@ -53,7 +53,7 @@ function body( course_code: "X", assignment: "hw1", blob_hash: "abc", - blob_uri: `aimdware/X/${session_id}.json`, + blob_uri: `aimdware/X/hw1/${session_id}.json`, blob_size: 0, ts: new Date(0).toISOString(), router_version: "0.0.0", @@ -71,7 +71,7 @@ test("sync stage reads .json (NOT .json) and PUTs those b expect(result).toEqual({ kind: "advance" }); expect(uploads).toHaveLength(1); - expect(uploads[0]!.path).toBe("/aimdware/X/S1.json"); + expect(uploads[0]!.path).toBe("/aimdware/X/hw1/S1.json"); expect(new TextDecoder().decode(uploads[0]!.bytes)).toBe("turn-1-blob"); }); @@ -99,7 +99,7 @@ test("sync stage uploads WHATEVER is currently on disk — a later turn's overwr // 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/S-race.json"); + 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 () => { diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts index 35a3c52..1a079fd 100644 --- a/llm-client/src/outbox/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -1,5 +1,11 @@ import { test, expect, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { + mkdtempSync, + rmSync, + writeFileSync, + existsSync, + mkdirSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; @@ -73,7 +79,7 @@ afterEach(() => { }); const NOW = 100_000_000; -const TTL = 7 * 24 * 3600 * 1000; +const TTL = 24 * 3600 * 1000; test("evicts a single-turn session past TTL: deletes .json + marks record", async () => { const { cacheDir, recordsDir, q } = fresh(); @@ -130,6 +136,29 @@ test("does NOT evict a session that still has in-flight turns", async () => { 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); @@ -182,6 +211,42 @@ test("tolerates a missing file (already removed out-of-band) — still marks rec 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++) { diff --git a/llm-client/src/outbox/eviction.ts b/llm-client/src/outbox/eviction.ts index b2ccb6a..18c50f8 100644 --- a/llm-client/src/outbox/eviction.ts +++ b/llm-client/src/outbox/eviction.ts @@ -16,7 +16,7 @@ export type EvictionSummary = { records_marked: number; }; -const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000; +const DEFAULT_TTL_MS = 24 * 3600 * 1000; const DEFAULT_LIMIT = 500; /** @@ -24,7 +24,7 @@ const DEFAULT_LIMIT = 500; * * 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 settled session past TTL: unlink + * 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). @@ -41,23 +41,28 @@ export async function runEvictionOnce( const threshold = now - ttlMs; const sessions = opts.queue.findEvictableSessions(threshold, limit); + let sessions_evicted = 0; let records_marked = 0; for (const s of sessions) { + let canMarkEvicted = true; try { await unlink(sessionBlobPath(opts.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) opts.queue.markEvicted(rid); + sessions_evicted += 1; records_marked += s.record_ids.length; } - return { sessions_evicted: sessions.length, records_marked }; + return { sessions_evicted, records_marked }; } export type EvictionLoopHandle = { stop: () => Promise }; diff --git a/llm-client/src/outbox/ingest-client.test.ts b/llm-client/src/outbox/ingest-client.test.ts index 6505eee..2cef348 100644 --- a/llm-client/src/outbox/ingest-client.test.ts +++ b/llm-client/src/outbox/ingest-client.test.ts @@ -41,8 +41,8 @@ function sampleBody(): IngestBody { turn_count: 1, course_code: "ECE4721J", assignment: "hw1", - blob_hash: "deadbeef", - blob_uri: "jbox://zhangsan/aimdware/ECE4721J/x.json", + 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, @@ -62,7 +62,7 @@ test("postContext sends Bearer student token + JSON body to /ingest/context", as 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("deadbeef"); + expect(sent.blob_hash).toBe("de".repeat(32)); }); test("202 -> created", async () => { diff --git a/llm-client/src/outbox/queue.ts b/llm-client/src/outbox/queue.ts index 1b798e0..a6c95af 100644 --- a/llm-client/src/outbox/queue.ts +++ b/llm-client/src/outbox/queue.ts @@ -238,8 +238,8 @@ export class IngestQueue { * Return sessions whose on-disk cache file is safe to delete. A session * is evictable when: * - * - every record in the session has state='done' (no in-flight turns - * that still need to read the file) + * - every record in the session is terminal: done, conflict, or fatal + * (no in-flight turns that still need to read 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 @@ -260,7 +260,7 @@ export class IngestQueue { FROM outbox WHERE session_id IS NOT NULL GROUP BY session_id - HAVING SUM(CASE WHEN state = 'done' THEN 0 ELSE 1 END) = 0 + HAVING SUM(CASE WHEN state IN ('done', 'conflict', 'fatal') THEN 0 ELSE 1 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 diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index ebf67f6..99e7d78 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -47,7 +47,7 @@ test("router metadata is at blob root; the whole parsed request lives under `req 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 reqText = JSON.stringify({ + const futureRequestText = JSON.stringify({ model: "x", messages: [{ role: "user", content: "hi" }], temperature: 0.7, @@ -72,7 +72,7 @@ test("ANY field on the request body is preserved verbatim (future-proof)", () => turn_count: 1, upstream_type: "openai", upstream_status: 200, - request_bytes: enc(reqText), + request_bytes: enc(futureRequestText), response_bytes: enc("{}"), }); const obj = JSON.parse(dec(r.blob_bytes)); @@ -90,7 +90,7 @@ test("ANY field on the request body is preserved verbatim (future-proof)", () => }); test("tools + tool_choice round-trip through `request`", () => { - const reqText = JSON.stringify({ + const toolRequestText = JSON.stringify({ model: "x", messages: [{ role: "user", content: "list files" }], tools: [ @@ -116,7 +116,7 @@ test("tools + tool_choice round-trip through `request`", () => { turn_count: 1, upstream_type: "openai", upstream_status: 200, - request_bytes: enc(reqText), + request_bytes: enc(toolRequestText), response_bytes: enc("{}"), }); const obj = JSON.parse(dec(r.blob_bytes)); diff --git a/wiki/backend.md b/wiki/backend.md index d1b668d..f87cd83 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -118,8 +118,8 @@ No relational integrity to enforce. ### Why `assignment` is a string, not a FK -Assignments are TT-decreed free-form labels (homework slug, lab number, -exam name). Modelling them as entities would force admin overhead +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. diff --git a/wiki/llm-client.md b/wiki/llm-client.md index d3bc597..ed93077 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -16,8 +16,8 @@ all off the student's critical path: ```yaml student_token: st_... # one per student; TT hands it directly -course: ECE4721J # course code; sent with every ingest call -assignment: hw1 # free-form label within the course; same payload-path level +course: ECE4721J # course code slug; sent with every ingest call +assignment: hw1 # TT-decreed slug; A-Z/a-z/0-9/_.- upstream: type: openai # default; only openai supported in v1 base_url: https://models.sjtu.edu.cn/api/v1 @@ -29,7 +29,7 @@ backend_url: https://aimdware.example.edu tbox_url: http://127.0.0.1:50471 tbox_user: alice tbox_pass: -# Optional: full override. Default = aimdware// +# Optional: must match the canonical default = aimdware// # jbox_remote_path: aimdware/ECE4721J/hw1 ``` @@ -174,9 +174,10 @@ router processes too. ## Eviction Session-keyed blob cache is reclaimable when **every** record sharing -that `session_id` has reached `state = done` AND the latest of them -was created more than `ttlMs` ago (default 7 days). One delete per -session; all member records get `cache_evicted = 1`. +that `session_id` has reached a terminal state (`done`, `conflict`, or +`fatal`) AND the latest of them was created more than `ttlMs` ago +(default 24 hours). One delete per session; all member records get +`cache_evicted = 1`. The queue row itself never deletes — it remains a per-record audit trail on the student's disk. From 156458bbd6f78cec44b92defc5af62ad026577da Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 19:42:58 +0800 Subject: [PATCH 35/60] fix(client): reclaim cache after successful upload --- llm-client/src/main.ts | 74 +++++++++++++++++--------- llm-client/src/outbox/eviction.test.ts | 70 +++++++++++++++++++++++- llm-client/src/outbox/eviction.ts | 35 ++++++++++-- llm-client/src/outbox/queue.ts | 35 ++++++++++-- llm-client/src/outbox/relay.test.ts | 19 +++++++ llm-client/src/outbox/relay.ts | 25 ++++++--- wiki/llm-client.md | 11 ++-- 7 files changed, 225 insertions(+), 44 deletions(-) diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 71b72f1..c319ac8 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -19,7 +19,10 @@ import { } from "./outbox/ingest-client"; import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./outbox/sync"; import { writeAtomic, bytesToHex, redactToken, sessionBlobPath } from "./util"; -import { startEvictionLoop } from "./outbox/eviction"; +import { + runSessionCacheCleanupOnce, + startEvictionLoop, +} from "./outbox/eviction"; import { tryParseJSON, decodeBytes } from "./recording/capture"; import { SessionTracker, type Message } from "./recording/session"; import { buildSessionBlob } from "./recording/session-blob"; @@ -160,6 +163,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const queue = new IngestQueue(queueDb); const sessionTracker = new SessionTracker(); + const pendingCacheWrites = new Set(); const webdavPut = makeWebDAVPut( config.tbox_url, config.tbox_user @@ -190,34 +194,39 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); }); const blobPath = sessionBlobPath(cacheDir, cls.session_id); + pendingCacheWrites.add(cls.session_id); 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, + 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`, ); - return; + } finally { + pendingCacheWrites.delete(cls.session_id); } - - 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`, - ); }, }); @@ -231,6 +240,19 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); queue, stages: buildStages(config, cacheDir, webdavPut), concurrency: 4, + afterAdvance: async (body, from, to) => { + if ( + from === "ingested" && + to === "synced" && + !pendingCacheWrites.has(body.session_id) + ) { + await runSessionCacheCleanupOnce({ + queue, + cacheDir, + session_id: body.session_id, + }); + } + }, }, 1000, ); diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts index 1a079fd..f3dc1e1 100644 --- a/llm-client/src/outbox/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -11,7 +11,7 @@ import { join } from "node:path"; import { Database } from "bun:sqlite"; import { IngestQueue } from "./queue"; import type { IngestBody } from "./ingest-client"; -import { runEvictionOnce } from "./eviction"; +import { runEvictionOnce, runSessionCacheCleanupOnce } from "./eviction"; const tmpDirs: string[] = []; function fresh() { @@ -174,6 +174,74 @@ test("does NOT evict when the latest turn is still within TTL", async () => { 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 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("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); diff --git a/llm-client/src/outbox/eviction.ts b/llm-client/src/outbox/eviction.ts index 18c50f8..3266d46 100644 --- a/llm-client/src/outbox/eviction.ts +++ b/llm-client/src/outbox/eviction.ts @@ -40,13 +40,42 @@ export async function runEvictionOnce( const limit = opts.limit ?? DEFAULT_LIMIT; const threshold = now - ttlMs; - const sessions = opts.queue.findEvictableSessions(threshold, limit); + return evictSessions( + opts.queue, + opts.cacheDir, + opts.queue.findEvictableSessions(threshold, limit), + ); +} + +export type SessionCacheCleanupOpts = { + queue: IngestQueue; + cacheDir: string; + session_id: string; +}; + +/** + * 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] : []); +} + +async function evictSessions( + queue: IngestQueue, + cacheDir: string, + sessions: Array<{ session_id: string; record_ids: string[] }>, +): Promise { let sessions_evicted = 0; let records_marked = 0; for (const s of sessions) { let canMarkEvicted = true; try { - await unlink(sessionBlobPath(opts.cacheDir, s.session_id)); + await unlink(sessionBlobPath(cacheDir, s.session_id)); } catch (e) { const code = (e as NodeJS.ErrnoException).code; if (code !== "ENOENT") { @@ -58,7 +87,7 @@ export async function runEvictionOnce( } } if (!canMarkEvicted) continue; - for (const rid of s.record_ids) opts.queue.markEvicted(rid); + for (const rid of s.record_ids) queue.markEvicted(rid); sessions_evicted += 1; records_marked += s.record_ids.length; } diff --git a/llm-client/src/outbox/queue.ts b/llm-client/src/outbox/queue.ts index a6c95af..4743a97 100644 --- a/llm-client/src/outbox/queue.ts +++ b/llm-client/src/outbox/queue.ts @@ -238,8 +238,8 @@ export class IngestQueue { * Return sessions whose on-disk cache file is safe to delete. A session * is evictable when: * - * - every record in the session is terminal: done, conflict, or fatal - * (no in-flight turns that still need to read the file) + * - 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 @@ -260,7 +260,7 @@ export class IngestQueue { FROM outbox WHERE session_id IS NOT NULL GROUP BY session_id - HAVING SUM(CASE WHEN state IN ('done', 'conflict', 'fatal') THEN 0 ELSE 1 END) = 0 + 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 @@ -276,6 +276,35 @@ export class IngestQueue { })); } + /** + * 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 = ?`) diff --git a/llm-client/src/outbox/relay.test.ts b/llm-client/src/outbox/relay.test.ts index 3790cfd..1c2ef2b 100644 --- a/llm-client/src/outbox/relay.test.ts +++ b/llm-client/src/outbox/relay.test.ts @@ -66,6 +66,25 @@ test("ingested + sync.advance -> state=synced", async () => { 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); diff --git a/llm-client/src/outbox/relay.ts b/llm-client/src/outbox/relay.ts index f29e855..9c5cce3 100644 --- a/llm-client/src/outbox/relay.ts +++ b/llm-client/src/outbox/relay.ts @@ -42,6 +42,11 @@ export type WorkerOpts = { now?: () => number; backoff?: number[]; concurrency?: number; + afterAdvance?: ( + body: IngestBody, + from: RecordState, + to: RecordState, + ) => void | Promise; }; export type RunSummary = { @@ -117,7 +122,7 @@ export async function runOnce( reason: (e as Error).message ?? "handler threw", }; } - applyResult( + const advancedTo = applyResult( opts.queue, slot.body.record_id, slot.state, @@ -125,6 +130,13 @@ export async function runOnce( 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; @@ -143,11 +155,12 @@ function applyResult( result: StageResult, now: number, backoff: number[], -): void { +): RecordState | undefined { switch (result.kind) { case "advance": { - queue.advance(recordId, nextStateAfter(currentState), now); - break; + const nextState = nextStateAfter(currentState); + queue.advance(recordId, nextState, now); + return nextState; } case "retry": { const attempts = queue.statusOf(recordId)?.attempts ?? 0; @@ -156,11 +169,11 @@ function applyResult( result.reason, now + nextBackoff(attempts, backoff), ); - break; + return undefined; } case "terminal": { queue.markTerminal(recordId, result.finalState, result.reason); - break; + return undefined; } } } diff --git a/wiki/llm-client.md b/wiki/llm-client.md index ed93077..87f0eeb 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -173,11 +173,12 @@ router processes too. ## Eviction -Session-keyed blob cache is reclaimable when **every** record sharing -that `session_id` has reached a terminal state (`done`, `conflict`, or -`fatal`) AND the latest of them was created more than `ttlMs` ago -(default 24 hours). One delete per session; all member records get -`cache_evicted = 1`. +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`. The queue row itself never deletes — it remains a per-record audit trail on the student's disk. From 0fb0396a4a9baa98b2a76d9922cfdac66c4cf5b8 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 19:43:46 +0800 Subject: [PATCH 36/60] chore: ignore agent.md --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d6f555e..be257df 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ .*.bun-build test-functional/runs/ test-functional/opencode.json + +# Personal runbook — do not commit +AGENT.md From 106a43ee45ffda224de4221c4eb282b988935302 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 20:59:12 +0800 Subject: [PATCH 37/60] fix(client): guard cache cleanup during pending writes --- llm-client/src/main.ts | 20 +++--- llm-client/src/outbox/eviction.test.ts | 86 +++++++++++++++++++++++++- llm-client/src/outbox/eviction.ts | 30 ++++++++- wiki/llm-client.md | 4 +- 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index c319ac8..e9c2deb 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -20,6 +20,7 @@ import { import { syncBlob, makeWebDAVPut, type WebDAVPutLike } from "./outbox/sync"; import { writeAtomic, bytesToHex, redactToken, sessionBlobPath } from "./util"; import { + PendingSessionWrites, runSessionCacheCleanupOnce, startEvictionLoop, } from "./outbox/eviction"; @@ -163,7 +164,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const queue = new IngestQueue(queueDb); const sessionTracker = new SessionTracker(); - const pendingCacheWrites = new Set(); + const pendingCacheWrites = new PendingSessionWrites(); const webdavPut = makeWebDAVPut( config.tbox_url, config.tbox_user @@ -194,7 +195,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); }); const blobPath = sessionBlobPath(cacheDir, cls.session_id); - pendingCacheWrites.add(cls.session_id); + pendingCacheWrites.begin(cls.session_id); try { try { await writeAtomic(blobPath, blob.blob_bytes); @@ -225,7 +226,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); `captured record=${result.record_id} session=${cls.session_id} turn=${cls.turn_count} hash=${hex}… size=${blob.blob_size} -> queued`, ); } finally { - pendingCacheWrites.delete(cls.session_id); + pendingCacheWrites.end(cls.session_id); } }, }); @@ -241,15 +242,12 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); stages: buildStages(config, cacheDir, webdavPut), concurrency: 4, afterAdvance: async (body, from, to) => { - if ( - from === "ingested" && - to === "synced" && - !pendingCacheWrites.has(body.session_id) - ) { + if (from === "ingested" && to === "synced") { await runSessionCacheCleanupOnce({ queue, cacheDir, session_id: body.session_id, + isSessionPending: pendingCacheWrites.has, }); } }, @@ -257,7 +255,11 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); 1000, ); - const eviction = startEvictionLoop({ queue, cacheDir }); + const eviction = startEvictionLoop({ + queue, + cacheDir, + isSessionPending: pendingCacheWrites.has, + }); console.log( `aimdware-router listening on http://${handle.hostname}:${handle.port}`, diff --git a/llm-client/src/outbox/eviction.test.ts b/llm-client/src/outbox/eviction.test.ts index f3dc1e1..85aab5f 100644 --- a/llm-client/src/outbox/eviction.test.ts +++ b/llm-client/src/outbox/eviction.test.ts @@ -11,7 +11,11 @@ import { join } from "node:path"; import { Database } from "bun:sqlite"; import { IngestQueue } from "./queue"; import type { IngestBody } from "./ingest-client"; -import { runEvictionOnce, runSessionCacheCleanupOnce } from "./eviction"; +import { + PendingSessionWrites, + runEvictionOnce, + runSessionCacheCleanupOnce, +} from "./eviction"; const tmpDirs: string[] = []; function fresh() { @@ -81,6 +85,17 @@ afterEach(() => { 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); @@ -198,6 +213,40 @@ test("cleanup deletes a session cache immediately once every turn is synced or l 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); @@ -242,6 +291,41 @@ test("ttl eviction cleans up old synced sessions if fast cleanup was missed", as 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); diff --git a/llm-client/src/outbox/eviction.ts b/llm-client/src/outbox/eviction.ts index 3266d46..f19d312 100644 --- a/llm-client/src/outbox/eviction.ts +++ b/llm-client/src/outbox/eviction.ts @@ -7,6 +7,7 @@ export type EvictionOpts = { cacheDir: string; now?: () => number; ttlMs?: number; + isSessionPending?: (session_id: string) => boolean; /** Max sessions to process per pass (not records). */ limit?: number; }; @@ -19,6 +20,24 @@ export type EvictionSummary = { 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. * @@ -44,6 +63,7 @@ export async function runEvictionOnce( opts.queue, opts.cacheDir, opts.queue.findEvictableSessions(threshold, limit), + opts.isSessionPending, ); } @@ -51,6 +71,7 @@ export type SessionCacheCleanupOpts = { queue: IngestQueue; cacheDir: string; session_id: string; + isSessionPending?: (session_id: string) => boolean; }; /** @@ -62,17 +83,24 @@ export async function runSessionCacheCleanupOnce( opts: SessionCacheCleanupOpts, ): Promise { const session = opts.queue.findReclaimableSession(opts.session_id); - return evictSessions(opts.queue, opts.cacheDir, session ? [session] : []); + 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)); diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 87f0eeb..e5e732e 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -178,7 +178,9 @@ 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`. +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. From 76537dad029ddf7f29db43a4bc057e2ca269281b Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 21:08:53 +0800 Subject: [PATCH 38/60] feat(client): add subscription provider plugins --- llm-client/aimdware.example.yaml | 18 +- llm-client/src/config.test.ts | 58 ++++++ llm-client/src/config.ts | 44 ++++- llm-client/src/http/handler.ts | 3 +- llm-client/src/http/proxy.ts | 41 ++-- llm-client/src/main.ts | 81 +++++++- llm-client/src/providers/auth-login.test.ts | 99 ++++++++++ llm-client/src/providers/auth-login.ts | 202 ++++++++++++++++++++ llm-client/src/providers/auth-store.ts | 55 ++++++ llm-client/src/providers/codex.ts | 132 +++++++++++++ llm-client/src/providers/copilot.ts | 87 +++++++++ llm-client/src/providers/index.ts | 26 +++ llm-client/src/providers/openai.ts | 26 +++ llm-client/src/providers/plugin.ts | 41 ++++ llm-client/src/providers/provider.test.ts | 119 ++++++++++++ wiki/llm-client.md | 31 ++- 16 files changed, 1018 insertions(+), 45 deletions(-) create mode 100644 llm-client/src/providers/auth-login.test.ts create mode 100644 llm-client/src/providers/auth-login.ts create mode 100644 llm-client/src/providers/auth-store.ts create mode 100644 llm-client/src/providers/codex.ts create mode 100644 llm-client/src/providers/copilot.ts create mode 100644 llm-client/src/providers/index.ts create mode 100644 llm-client/src/providers/openai.ts create mode 100644 llm-client/src/providers/plugin.ts create mode 100644 llm-client/src/providers/provider.test.ts diff --git a/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml index 64fea7c..a13b831 100644 --- a/llm-client/aimdware.example.yaml +++ b/llm-client/aimdware.example.yaml @@ -32,13 +32,25 @@ tbox_url: http://127.0.0.1:50471 tbox_user: REPLACE_ME tbox_pass: REPLACE_ME -# Upstream LLM provider (any OpenAI-compatible API works — OpenAI, -# Kimi, GLM, DeepSeek, Qwen, chatanywhere, …). +# Upstream LLM provider. `openai` means any OpenAI-compatible API +# (OpenAI, Kimi, GLM, DeepSeek, Qwen, chatanywhere, ...). `codex` +# and `copilot` use local subscription auth plugins; run: +# ./aimdware-router --config ./aimdware.yaml auth login codex +# or: +# ./aimdware-router --config ./aimdware.yaml auth login copilot upstream: - type: openai + 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 diff --git a/llm-client/src/config.test.ts b/llm-client/src/config.test.ts index d878c18..5eaf9d9 100644 --- a/llm-client/src/config.test.ts +++ b/llm-client/src/config.test.ts @@ -120,3 +120,61 @@ 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 index 6f0266b..1485dec 100644 --- a/llm-client/src/config.ts +++ b/llm-client/src/config.ts @@ -7,11 +7,45 @@ const SlugSchema = z .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"]).default("openai"), - base_url: z.string().default("https://api.openai.com"), - api_key: z.string().min(1, "upstream.api_key is required"), -}); +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"), diff --git a/llm-client/src/http/handler.ts b/llm-client/src/http/handler.ts index 47a1b3f..554e73b 100644 --- a/llm-client/src/http/handler.ts +++ b/llm-client/src/http/handler.ts @@ -1,8 +1,9 @@ import { captureChat, type CaptureResult } from "../recording/capture"; import { proxyChat, type FetchLike, type UpstreamConfig } from "./proxy"; +import type { ProviderRuntime } from "../providers/plugin"; export type HandlerOpts = { - upstream: UpstreamConfig; + upstream: UpstreamConfig | ProviderRuntime; onCapture?: (result: CaptureResult) => void | Promise; fetchImpl?: FetchLike; }; diff --git a/llm-client/src/http/proxy.ts b/llm-client/src/http/proxy.ts index 336e530..8f8261b 100644 --- a/llm-client/src/http/proxy.ts +++ b/llm-client/src/http/proxy.ts @@ -1,4 +1,6 @@ import { getProxyForUrl } from "./net"; +import { createOpenAIProvider } from "../providers/openai"; +import type { ProviderRuntime } from "../providers/plugin"; export type UpstreamConfig = { base_url: string; @@ -29,20 +31,10 @@ const HOP_BY_HOP_HEADERS = new Set([ export async function proxyChat( inbound: Request, - upstream: UpstreamConfig, + upstream: UpstreamConfig | ProviderRuntime, opts: ProxyChatOpts = {}, ): Promise { const inboundUrl = new URL(inbound.url); - // We can't use `new URL(absolutePath, base)` because that drops base's - // path component. Concatenate explicitly, then dedupe a `/v1` if the - // user put it in both `base_url` and (per OpenAI convention) we got - // `/v1/chat/completions` inbound. - const base = upstream.base_url.replace(/\/+$/, ""); - let path = inboundUrl.pathname; - if (base.endsWith("/v1") && path.startsWith("/v1/")) { - path = path.slice("/v1".length); - } - const target = new URL(base + path + inboundUrl.search); const forwardedHeaders = new Headers(); inbound.headers.forEach((value, key) => { @@ -50,22 +42,31 @@ export async function proxyChat( forwardedHeaders.set(key, value); } }); - forwardedHeaders.set("authorization", `Bearer ${upstream.api_key}`); - const proxy = getProxyForUrl(target); + const body = + inbound.method === "GET" || inbound.method === "HEAD" + ? undefined + : await inbound.arrayBuffer(); + const provider = + "prepareChat" in upstream ? upstream : createOpenAIProvider(upstream); + const prepared = await provider.prepareChat({ + 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: inbound.method, - headers: forwardedHeaders, - body: - inbound.method === "GET" || inbound.method === "HEAD" - ? undefined - : await inbound.arrayBuffer(), + 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(target, init); + const upstreamRes = await f(prepared.url, init); return new Response(upstreamRes.body, { status: upstreamRes.status, diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index e9c2deb..0631537 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -6,6 +6,10 @@ import { homedir } from "node:os"; import { loadConfig, type Config } from "./config"; import { createHandler } from "./http/handler"; import { startServer } from "./http/server"; +import { 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, @@ -44,6 +48,46 @@ function extractMessages(requestBytes: Uint8Array): 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 @@ -124,13 +168,14 @@ function buildStages( } async function main() { - const { values } = parseArgs({ + 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: false, + allowPositionals: true, }); if (values.help) { @@ -138,6 +183,9 @@ async function main() { 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 @@ -161,10 +209,23 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const cacheDir = expandHome(config.local_cache_dir); await mkdir(join(cacheDir, "records"), { recursive: true }); const queueDb = join(cacheDir, "queue.db"); + const authStore = createFileAuthStore(join(cacheDir, "auth.json")); + + 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 @@ -173,10 +234,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); ); const handler = createHandler({ - upstream: { - base_url: config.upstream.base_url, - api_key: config.upstream.api_key, - }, + upstream: provider, onCapture: async (result) => { const messages = extractMessages(result.request_bytes); const cls = sessionTracker.classify(messages, result.ts); @@ -264,10 +322,13 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); console.log( `aimdware-router listening on http://${handle.hostname}:${handle.port}`, ); - console.log( - ` upstream: ${config.upstream.base_url} (${config.upstream.type})`, - ); - console.log(` upstream key: ${redactToken(config.upstream.api_key)}`); + 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: ${join(cacheDir, "auth.json")}`); + } console.log(` student token: ${redactToken(config.student_token)}`); console.log(` course: ${config.course}`); console.log(` backend: ${config.backend_url}`); 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..6288b22 --- /dev/null +++ b/llm-client/src/providers/auth-login.test.ts @@ -0,0 +1,99 @@ +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"; + +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); + }, + }; +} + +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("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, + }); +}); diff --git a/llm-client/src/providers/auth-login.ts b/llm-client/src/providers/auth-login.ts new file mode 100644 index 0000000..c6ffdaf --- /dev/null +++ b/llm-client/src/providers/auth-login.ts @@ -0,0 +1,202 @@ +import type { FetchLike } from "../http/proxy"; +import type { AuthStore, OAuthAuth } from "./auth-store"; +import { extractCodexAccountId } from "./codex"; +import { 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; +}; + +type CodexTokenResponse = { + access_token: string; + refresh_token: string; + expires_in?: 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 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; + }; + const interval = Math.max(Number.parseInt(deviceData.interval) || 5, 1); + d.notify(`Open ${CODEX_ISSUER}/codex/device`); + d.notify(`Enter code: ${deviceData.user_code}`); + + while (true) { + const response = await 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 d.fetchImpl(`${CODEX_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + 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 = (await tokenResponse.json()) as CodexTokenResponse; + 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 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 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.ts b/llm-client/src/providers/auth-store.ts new file mode 100644 index 0000000..8c557e8 --- /dev/null +++ b/llm-client/src/providers/auth-store.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { dirname } 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; +}; + +type AuthFile = { + providers?: Partial>; +}; + +export function createFileAuthStore(path: string): AuthStore { + 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; + } + } + + return { + async get(id) { + return (await readAll()).providers?.[id]; + }, + async set(id, auth) { + const file = await readAll(); + const next: AuthFile = { + ...file, + providers: { + ...file.providers, + [id]: auth, + }, + }; + await mkdir(dirname(path), { recursive: true }); + await writeAtomic(path, new TextEncoder().encode(JSON.stringify(next))); + }, + }; +} diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts new file mode 100644 index 0000000..311a87c --- /dev/null +++ b/llm-client/src/providers/codex.ts @@ -0,0 +1,132 @@ +import type { AuthStore, OAuthAuth } from "./auth-store"; +import type { ProviderFetchOpts, ProviderRuntime } from "./plugin"; +import { 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"; + +type TokenResponse = { + id_token?: string; + access_token: string; + refresh_token: string; + expires_in?: number; +}; + +type JwtClaims = { + chatgpt_account_id?: string; + organizations?: Array<{ id: string }>; + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string; + }; +}; + +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 { + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ); +} + +export function extractCodexAccountId( + tokens: TokenResponse, +): 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; +} + +async function refreshAccessToken( + refreshToken: string, + opts: Required, +): Promise { + const response = await opts.fetchImpl(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }); + if (!response.ok) { + throw new Error(`Codex token refresh failed: ${response.status}`); + } + return (await response.json()) as TokenResponse; +} + +async function currentAuth(opts: CodexProviderOpts): Promise { + const now = opts.now ?? Date.now; + const fetchImpl = + opts.fetchImpl ?? (fetch as unknown as ProviderFetchOpts["fetchImpl"]); + const auth = await opts.authStore.get("codex"); + if (!auth || auth.type !== "oauth") { + throw new Error( + "Codex subscription is not logged in. Run `aimdware-router auth login codex` first.", + ); + } + + if (auth.access && auth.expires > now()) return auth; + + 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; +} + +export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { + return { + id: "codex", + label: "ChatGPT Codex subscription", + async prepareChat(input) { + const auth = await currentAuth(opts); + const headers = new Headers(input.headers); + headers.delete("authorization"); + headers.delete("Authorization"); + 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); + + const isChatRequest = + input.inboundUrl.pathname.includes("/v1/responses") || + input.inboundUrl.pathname.includes("/chat/completions"); + return { + url: isChatRequest ? new URL(CODEX_API_ENDPOINT) : input.inboundUrl, + method: input.method, + headers, + body: input.body, + }; + }, + }; +} diff --git a/llm-client/src/providers/copilot.ts b/llm-client/src/providers/copilot.ts new file mode 100644 index 0000000..2938c6a --- /dev/null +++ b/llm-client/src/providers/copilot.ts @@ -0,0 +1,87 @@ +import type { AuthStore, OAuthAuth } from "./auth-store"; +import type { ProviderRuntime } from "./plugin"; +import { openAICompatibleUrl, userAgent } from "./plugin"; + +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 { + if (!body) return false; + try { + const parsed = JSON.parse(new TextDecoder().decode(body)) as { + messages?: Array<{ content?: unknown }>; + input?: Array<{ content?: unknown }>; + }; + const messages = parsed.messages ?? parsed.input ?? []; + return messages.some((msg) => { + const content = msg.content; + return ( + Array.isArray(content) && + content.some( + (part) => + typeof part === "object" && + part !== null && + ["image_url", "input_image", "image"].includes( + String((part as { type?: unknown }).type), + ), + ) + ); + }); + } catch { + return false; + } +} + +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 { + return { + id: "copilot", + label: "GitHub Copilot subscription", + async prepareChat(input) { + 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", "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, + }; + }, + }; +} 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..6a9010d --- /dev/null +++ b/llm-client/src/providers/openai.ts @@ -0,0 +1,26 @@ +import type { ProviderRuntime } from "./plugin"; +import { openAICompatibleUrl } from "./plugin"; + +export type OpenAIProviderConfig = { + base_url: string; + api_key: string; +}; + +export function createOpenAIProvider( + config: OpenAIProviderConfig, +): ProviderRuntime { + return { + id: "openai", + label: "OpenAI-compatible API", + async prepareChat(input) { + 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, + }; + }, + }; +} diff --git a/llm-client/src/providers/plugin.ts b/llm-client/src/providers/plugin.ts new file mode 100644 index 0000000..221490c --- /dev/null +++ b/llm-client/src/providers/plugin.ts @@ -0,0 +1,41 @@ +import type { FetchLike } from "../http/proxy"; + +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; +}; + +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"}`; +} diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts new file mode 100644 index 0000000..d3084c5 --- /dev/null +++ b/llm-client/src/providers/provider.test.ts @@ -0,0 +1,119 @@ +import { test, expect } from "bun:test"; +import { proxyChat, type FetchLike } from "../http/proxy"; +import { createCodexProvider } from "./codex"; +import { createCopilotProvider } from "./copilot"; +import type { AuthStore, ProviderAuth } from "./auth-store"; + +function authStore(initial: ProviderAuth): AuthStore { + let value = initial; + return { + async get(id) { + expect(["codex", "copilot"]).toContain(id); + return value; + }, + async set(_id, next) { + value = next; + }, + }; +} + +test("codex provider refreshes oauth and rewrites chat 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 proxyChat( + new Request("http://router-local/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer client-token" }, + body: JSON.stringify({ model: "gpt-5.3-codex", messages: [] }), + }), + 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["chatgpt-account-id"]).toBe("acct-old"); +}); + +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"); +}); diff --git a/wiki/llm-client.md b/wiki/llm-client.md index e5e732e..46affd7 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -19,9 +19,9 @@ 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: - type: openai # default; only openai supported in v1 + plugin: openai # openai, codex, or copilot; `type` remains an alias base_url: https://models.sjtu.edu.cn/api/v1 - api_key: sk-... # student's own + 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 @@ -33,18 +33,37 @@ tbox_pass: # 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: + plugin: codex # or copilot +``` + +The subscription tokens are stored in `local_cache_dir/auth.json`, not +in `aimdware.yaml`. + ### 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 | call the student's chosen LLM provider | +| `upstream.api_key` | same file, only for `plugin: openai` | call the student's chosen LLM provider | +| subscription OAuth tokens | `local_cache_dir/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, all three secrets are compromised. The -backend can mint a new `student_token` via `aimdware-admin token issue`; -LLM provider and WebDAV credentials are the student's own to rotate. +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_*" From 093b0773ad4d7d43698240fcf384f3adfb04562a Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 21:38:03 +0800 Subject: [PATCH 39/60] feat(client): support responses protocol --- llm-client/aimdware.example.yaml | 7 +- llm-client/src/http/handler.test.ts | 97 +++++++++++++++++++++++ llm-client/src/http/handler.ts | 60 ++++++++++++-- llm-client/src/http/proxy.test.ts | 33 +++++++- llm-client/src/http/proxy.ts | 21 ++++- llm-client/src/main-extract.test.ts | 35 ++++++++ llm-client/src/main.ts | 4 +- llm-client/src/providers/codex.ts | 52 +++++++----- llm-client/src/providers/copilot.ts | 53 +++++++------ llm-client/src/providers/openai.ts | 25 +++--- llm-client/src/providers/plugin.ts | 10 +++ llm-client/src/providers/provider.test.ts | 29 +++++-- wiki/llm-client.md | 21 ++++- 13 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 llm-client/src/main-extract.test.ts diff --git a/llm-client/aimdware.example.yaml b/llm-client/aimdware.example.yaml index a13b831..bfdda95 100644 --- a/llm-client/aimdware.example.yaml +++ b/llm-client/aimdware.example.yaml @@ -33,8 +33,11 @@ tbox_user: REPLACE_ME tbox_pass: REPLACE_ME # Upstream LLM provider. `openai` means any OpenAI-compatible API -# (OpenAI, Kimi, GLM, DeepSeek, Qwen, chatanywhere, ...). `codex` -# and `copilot` use local subscription auth plugins; run: +# (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 diff --git a/llm-client/src/http/handler.test.ts b/llm-client/src/http/handler.test.ts index 69546af..9a9a712 100644 --- a/llm-client/src/http/handler.test.ts +++ b/llm-client/src/http/handler.test.ts @@ -2,6 +2,8 @@ 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; @@ -28,6 +30,18 @@ function startFakeUpstream( 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() {}, +}; + test("GET /healthz returns 200 ok", async () => { const handler = createHandler({ upstream: { base_url: "https://unused", api_key: "x" }, @@ -75,6 +89,52 @@ test("POST /v1/chat/completions forwards request to upstream", async () => { 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( () => @@ -112,6 +172,43 @@ test("POST /v1/chat/completions fires onCapture in background with full blob", a 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(() => { diff --git a/llm-client/src/http/handler.ts b/llm-client/src/http/handler.ts index 554e73b..35e9715 100644 --- a/llm-client/src/http/handler.ts +++ b/llm-client/src/http/handler.ts @@ -1,6 +1,14 @@ import { captureChat, type CaptureResult } from "../recording/capture"; -import { proxyChat, type FetchLike, type UpstreamConfig } from "./proxy"; -import type { ProviderRuntime } from "../providers/plugin"; +import { + proxyChat, + proxyResponses, + type FetchLike, + type UpstreamConfig, +} from "./proxy"; +import { + UnsupportedProviderProtocolError, + type ProviderRuntime, +} from "../providers/plugin"; export type HandlerOpts = { upstream: UpstreamConfig | ProviderRuntime; @@ -19,17 +27,57 @@ export function createHandler(opts: HandlerOpts): RequestHandler { } if (req.method === "POST" && url.pathname === "/v1/chat/completions") { - return handleChat(req, opts); + 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()); - // proxyChat reads from a Request; rebuild one carrying the body we just + // 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, @@ -37,9 +85,7 @@ async function handleChat(req: Request, opts: HandlerOpts): Promise { body: requestBytes, }); - const upstreamRes = await proxyChat(proxyReq, opts.upstream, { - fetchImpl: opts.fetchImpl, - }); + const upstreamRes = await proxy(proxyReq); const { clientResponse, captureP } = captureChat(requestBytes, upstreamRes); diff --git a/llm-client/src/http/proxy.test.ts b/llm-client/src/http/proxy.test.ts index 869465e..5c64507 100644 --- a/llm-client/src/http/proxy.test.ts +++ b/llm-client/src/http/proxy.test.ts @@ -1,6 +1,6 @@ import { test, expect, afterEach, beforeEach } from "bun:test"; import type { Server } from "bun"; -import { proxyChat, type FetchLike } from "./proxy"; +import { proxyChat, proxyResponses, type FetchLike } from "./proxy"; beforeEach(() => { delete process.env.HTTP_PROXY; @@ -241,3 +241,34 @@ test("proxyChat does not double-prefix /v1 when base_url already ends with /v1", "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 index 8f8261b..bfa6f32 100644 --- a/llm-client/src/http/proxy.ts +++ b/llm-client/src/http/proxy.ts @@ -33,6 +33,23 @@ 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); @@ -49,7 +66,9 @@ export async function proxyChat( : await inbound.arrayBuffer(); const provider = "prepareChat" in upstream ? upstream : createOpenAIProvider(upstream); - const prepared = await provider.prepareChat({ + const prepare = + protocol === "chat" ? provider.prepareChat : provider.prepareResponses; + const prepared = await prepare({ inboundUrl, method: inbound.method, headers: forwardedHeaders, 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.ts b/llm-client/src/main.ts index 0631537..8732c2c 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -39,11 +39,13 @@ function expandHome(p: string): string { return p.startsWith("~/") || p === "~" ? join(homedir(), p.slice(1)) : p; } -function extractMessages(requestBytes: Uint8Array): Message[] { +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 []; } diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts index 311a87c..c42b61b 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -1,5 +1,9 @@ import type { AuthStore, OAuthAuth } from "./auth-store"; -import type { ProviderFetchOpts, ProviderRuntime } from "./plugin"; +import { + UnsupportedProviderProtocolError, + type ProviderFetchOpts, + type ProviderRuntime, +} from "./plugin"; import { userAgent } from "./plugin"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -104,29 +108,35 @@ async function currentAuth(opts: CodexProviderOpts): Promise { } export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { + const prepareResponses = async ( + input: Parameters[0], + ) => { + const auth = await currentAuth(opts); + const headers = new Headers(input.headers); + headers.delete("authorization"); + headers.delete("Authorization"); + 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: input.body, + }; + }; + return { id: "codex", label: "ChatGPT Codex subscription", - async prepareChat(input) { - const auth = await currentAuth(opts); - const headers = new Headers(input.headers); - headers.delete("authorization"); - headers.delete("Authorization"); - 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); - - const isChatRequest = - input.inboundUrl.pathname.includes("/v1/responses") || - input.inboundUrl.pathname.includes("/chat/completions"); - return { - url: isChatRequest ? new URL(CODEX_API_ENDPOINT) : input.inboundUrl, - method: input.method, - headers, - body: input.body, - }; + 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 index 2938c6a..39badb9 100644 --- a/llm-client/src/providers/copilot.ts +++ b/llm-client/src/providers/copilot.ts @@ -56,32 +56,37 @@ async function currentAuth(authStore: AuthStore): Promise { 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", "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", - async prepareChat(input) { - 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", "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, - }; - }, + prepareChat: prepare, + prepareResponses: prepare, }; } diff --git a/llm-client/src/providers/openai.ts b/llm-client/src/providers/openai.ts index 6a9010d..fb5ade5 100644 --- a/llm-client/src/providers/openai.ts +++ b/llm-client/src/providers/openai.ts @@ -9,18 +9,23 @@ export type OpenAIProviderConfig = { 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", - async prepareChat(input) { - 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, - }; - }, + prepareChat: prepare, + prepareResponses: prepare, }; } diff --git a/llm-client/src/providers/plugin.ts b/llm-client/src/providers/plugin.ts index 221490c..01cef76 100644 --- a/llm-client/src/providers/plugin.ts +++ b/llm-client/src/providers/plugin.ts @@ -20,8 +20,18 @@ 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; diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts index d3084c5..78bbffe 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { proxyChat, type FetchLike } from "../http/proxy"; +import { proxyChat, proxyResponses, type FetchLike } from "../http/proxy"; import { createCodexProvider } from "./codex"; import { createCopilotProvider } from "./copilot"; import type { AuthStore, ProviderAuth } from "./auth-store"; @@ -17,7 +17,7 @@ function authStore(initial: ProviderAuth): AuthStore { }; } -test("codex provider refreshes oauth and rewrites chat requests to the Codex endpoint", async () => { +test("codex provider refreshes oauth and rewrites Responses requests to the Codex endpoint", async () => { const store = authStore({ type: "oauth", access: "expired-access", @@ -49,11 +49,11 @@ test("codex provider refreshes oauth and rewrites chat requests to the Codex end return new Response('{"ok":true}', { status: 200 }); }; - await proxyChat( - new Request("http://router-local/v1/chat/completions", { + await proxyResponses( + new Request("http://router-local/v1/responses", { method: "POST", headers: { authorization: "Bearer client-token" }, - body: JSON.stringify({ model: "gpt-5.3-codex", messages: [] }), + body: JSON.stringify({ model: "gpt-5.3-codex", input: [] }), }), createCodexProvider({ authStore: store, fetchImpl: refreshFetch }), { fetchImpl: upstreamFetch }, @@ -67,6 +67,25 @@ test("codex provider refreshes oauth and rewrites chat requests to the Codex end expect(upstreamCalls[0]!.headers["chatgpt-account-id"]).toBe("acct-old"); }); +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", diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 46affd7..31fda50 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -1,8 +1,9 @@ # LLM client Single binary on the student's machine. Sits between the coding agent -and an OpenAI-compatible upstream. Three things happen per chat call, -all off the student's critical path: +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: 1. **forward** the request to upstream, stream the response back to the client byte-for-byte; @@ -50,6 +51,22 @@ upstream: The subscription tokens are stored in `local_cache_dir/auth.json`, 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. + ### What the router holds and what it doesn't | Credential | Where | What it can do | From cec981357b87aab2f3906508673052f06764bb10 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sat, 16 May 2026 22:27:21 +0800 Subject: [PATCH 40/60] fix(client): align subscription providers with opencode --- llm-client/src/http/net.test.ts | 18 +++ llm-client/src/http/net.ts | 11 +- llm-client/src/providers/auth-login.test.ts | 115 ++++++++++++++ llm-client/src/providers/auth-login.ts | 38 +++-- llm-client/src/providers/codex.ts | 42 +++-- llm-client/src/providers/copilot.ts | 85 +++++++--- llm-client/src/providers/plugin.ts | 13 ++ llm-client/src/providers/provider.test.ts | 149 +++++++++++++++++- llm-client/src/recording/session-blob.test.ts | 39 +++++ llm-client/src/recording/session-blob.ts | 14 +- 10 files changed, 467 insertions(+), 57 deletions(-) diff --git a/llm-client/src/http/net.test.ts b/llm-client/src/http/net.test.ts index 77a44cf..2f9ac2c 100644 --- a/llm-client/src/http/net.test.ts +++ b/llm-client/src/http/net.test.ts @@ -68,6 +68,16 @@ test("NO_PROXY exact host match disables proxy", () => { 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"; @@ -76,6 +86,14 @@ test("NO_PROXY suffix match (.example.com matches sub.example.com)", () => { 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 = "*"; diff --git a/llm-client/src/http/net.ts b/llm-client/src/http/net.ts index 0934adc..5a3c521 100644 --- a/llm-client/src/http/net.ts +++ b/llm-client/src/http/net.ts @@ -46,7 +46,7 @@ function isLoopback(host: string): boolean { function matchesNoProxy(host: string, noProxy: string): boolean { const entries = noProxy .split(",") - .map((s) => s.trim().toLowerCase()) + .map((s) => normalizeNoProxyEntry(s.trim().toLowerCase())) .filter(Boolean); for (const entry of entries) { if (entry === "*") return true; @@ -54,9 +54,16 @@ function matchesNoProxy(host: string, noProxy: string): boolean { // ".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) { + } 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/providers/auth-login.test.ts b/llm-client/src/providers/auth-login.test.ts index 6288b22..92119f0 100644 --- a/llm-client/src/providers/auth-login.test.ts +++ b/llm-client/src/providers/auth-login.test.ts @@ -3,6 +3,39 @@ 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 { @@ -65,6 +98,52 @@ test("loginCodexDevice stores refreshed oauth credentials", async () => { }); }); +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("loginCopilotDevice stores GitHub Copilot oauth credentials", async () => { const store = memoryStore(); const prompts: string[] = []; @@ -97,3 +176,39 @@ test("loginCopilotDevice stores GitHub Copilot oauth credentials", async () => { 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 index c6ffdaf..2b4eeb4 100644 --- a/llm-client/src/providers/auth-login.ts +++ b/llm-client/src/providers/auth-login.ts @@ -1,7 +1,7 @@ import type { FetchLike } from "../http/proxy"; import type { AuthStore, OAuthAuth } from "./auth-store"; import { extractCodexAccountId } from "./codex"; -import { userAgent } from "./plugin"; +import { fetchWithProxy, userAgent } from "./plugin"; const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const CODEX_ISSUER = "https://auth.openai.com"; @@ -36,7 +36,8 @@ function defaults(opts: LoginOpts): Required { export async function loginCodexDevice(opts: LoginOpts): Promise { const d = defaults(opts); - const deviceResponse = await d.fetchImpl( + const deviceResponse = await fetchWithProxy( + d.fetchImpl, `${CODEX_ISSUER}/api/accounts/deviceauth/usercode`, { method: "POST", @@ -63,7 +64,8 @@ export async function loginCodexDevice(opts: LoginOpts): Promise { d.notify(`Enter code: ${deviceData.user_code}`); while (true) { - const response = await d.fetchImpl( + const response = await fetchWithProxy( + d.fetchImpl, `${CODEX_ISSUER}/api/accounts/deviceauth/token`, { method: "POST", @@ -83,17 +85,21 @@ export async function loginCodexDevice(opts: LoginOpts): Promise { authorization_code: string; code_verifier: string; }; - const tokenResponse = await d.fetchImpl(`${CODEX_ISSUER}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - 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(), - }); + const tokenResponse = await fetchWithProxy( + d.fetchImpl, + `${CODEX_ISSUER}/oauth/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + 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}`); } @@ -125,7 +131,7 @@ export async function loginCopilotDevice( : "github.com"; const deviceUrl = `https://${domain}/login/device/code`; const tokenUrl = `https://${domain}/login/oauth/access_token`; - const deviceResponse = await d.fetchImpl(deviceUrl, { + const deviceResponse = await fetchWithProxy(d.fetchImpl, deviceUrl, { method: "POST", headers: { Accept: "application/json", @@ -153,7 +159,7 @@ export async function loginCopilotDevice( d.notify(`Enter code: ${deviceData.user_code}`); while (true) { - const response = await d.fetchImpl(tokenUrl, { + const response = await fetchWithProxy(d.fetchImpl, tokenUrl, { method: "POST", headers: { Accept: "application/json", diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts index c42b61b..4872ee0 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -4,7 +4,7 @@ import { type ProviderFetchOpts, type ProviderRuntime, } from "./plugin"; -import { userAgent } from "./plugin"; +import { fetchWithProxy, userAgent } from "./plugin"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const ISSUER = "https://auth.openai.com"; @@ -63,15 +63,19 @@ async function refreshAccessToken( refreshToken: string, opts: Required, ): Promise { - const response = await opts.fetchImpl(`${ISSUER}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }).toString(), - }); + const response = await fetchWithProxy( + opts.fetchImpl, + `${ISSUER}/oauth/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }, + ); if (!response.ok) { throw new Error(`Codex token refresh failed: ${response.status}`); } @@ -107,6 +111,21 @@ async function currentAuth(opts: CodexProviderOpts): Promise { return next; } +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 prepareResponses = async ( input: Parameters[0], @@ -115,6 +134,7 @@ export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { 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()); @@ -125,7 +145,7 @@ export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { url: new URL(CODEX_API_ENDPOINT), method: input.method, headers, - body: input.body, + body: codexRequestBody(input.body), }; }; diff --git a/llm-client/src/providers/copilot.ts b/llm-client/src/providers/copilot.ts index 39badb9..4de9957 100644 --- a/llm-client/src/providers/copilot.ts +++ b/llm-client/src/providers/copilot.ts @@ -2,6 +2,8 @@ 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; }; @@ -17,30 +19,73 @@ function base(enterpriseUrl?: string): string { } function isVisionBody(body: ArrayBuffer | undefined): boolean { - if (!body) return false; + 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<{ content?: unknown }>; - input?: Array<{ content?: unknown }>; + messages?: Array<{ role?: unknown; content?: unknown }>; + input?: Array<{ role?: unknown; content?: unknown }>; }; - const messages = parsed.messages ?? parsed.input ?? []; - return messages.some((msg) => { - const content = msg.content; + 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 ( - Array.isArray(content) && - content.some( - (part) => - typeof part === "object" && - part !== null && - ["image_url", "input_image", "image"].includes( - String((part as { type?: unknown }).type), - ), - ) + (typed.type === "text" || typed.type === "input_text") && + typed.text === SYNTHETIC_ATTACHMENT_PROMPT ); - }); - } catch { - return false; - } + }) + ); } async function currentAuth(authStore: AuthStore): Promise { @@ -67,7 +112,7 @@ export function createCopilotProvider( headers.set("authorization", `Bearer ${auth.refresh}`); headers.set("User-Agent", userAgent()); headers.set("Openai-Intent", "conversation-edits"); - headers.set("x-initiator", "user"); + headers.set("x-initiator", isAgentBody(input.body) ? "agent" : "user"); if (isVisionBody(input.body)) { headers.set("Copilot-Vision-Request", "true"); } diff --git a/llm-client/src/providers/plugin.ts b/llm-client/src/providers/plugin.ts index 01cef76..e5a8b48 100644 --- a/llm-client/src/providers/plugin.ts +++ b/llm-client/src/providers/plugin.ts @@ -1,4 +1,5 @@ import type { FetchLike } from "../http/proxy"; +import { getProxyForUrl } from "../http/net"; export type ProviderId = "openai" | "codex" | "copilot"; @@ -49,3 +50,15 @@ export function openAICompatibleUrl(baseUrl: string, inboundUrl: URL): URL { 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 index 78bbffe..416d913 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -4,6 +4,39 @@ import { createCodexProvider } from "./codex"; import { createCopilotProvider } from "./copilot"; import type { AuthStore, ProviderAuth } from "./auth-store"; +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 = initial; return { @@ -52,7 +85,10 @@ test("codex provider refreshes oauth and rewrites Responses requests to the Code await proxyResponses( new Request("http://router-local/v1/responses", { method: "POST", - headers: { authorization: "Bearer client-token" }, + 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 }), @@ -64,9 +100,85 @@ test("codex provider refreshes oauth and rewrites Responses requests to the Code "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() + 60_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", @@ -136,3 +248,38 @@ test("copilot provider targets GitHub Copilot and adds subscription headers", as 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"); +}); diff --git a/llm-client/src/recording/session-blob.test.ts b/llm-client/src/recording/session-blob.test.ts index 99e7d78..b626a93 100644 --- a/llm-client/src/recording/session-blob.test.ts +++ b/llm-client/src/recording/session-blob.test.ts @@ -166,6 +166,45 @@ test("streaming SSE response stays as a raw string under `response`", () => { 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", diff --git a/llm-client/src/recording/session-blob.ts b/llm-client/src/recording/session-blob.ts index 121cb3a..69a3a1b 100644 --- a/llm-client/src/recording/session-blob.ts +++ b/llm-client/src/recording/session-blob.ts @@ -29,17 +29,17 @@ export type SessionBlobResult = { * { * session_id, course, started_at, latest_ts, turn_count, * upstream: { type }, upstream_status, - * request: , + * request: , * response: * } * - * Consumers read `request.messages`, `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. + * 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); From e3a71e64876f2682ca3eb0a0caa9195020a0e92b Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 14:15:31 +0800 Subject: [PATCH 41/60] feat(client): harden auth store with del, atomic 0600 writes, serialized mutations --- llm-client/src/http/handler.test.ts | 1 + llm-client/src/providers/auth-login.test.ts | 3 ++ llm-client/src/providers/auth-store.test.ts | 59 +++++++++++++++++++++ llm-client/src/providers/auth-store.ts | 39 ++++++++++---- llm-client/src/providers/provider.test.ts | 5 +- llm-client/src/util.ts | 8 ++- 6 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 llm-client/src/providers/auth-store.test.ts diff --git a/llm-client/src/http/handler.test.ts b/llm-client/src/http/handler.test.ts index 9a9a712..c5a9dcf 100644 --- a/llm-client/src/http/handler.test.ts +++ b/llm-client/src/http/handler.test.ts @@ -40,6 +40,7 @@ const loggedInCodexStore: AuthStore = { }; }, async set() {}, + async del() {}, }; test("GET /healthz returns 200 ok", async () => { diff --git a/llm-client/src/providers/auth-login.test.ts b/llm-client/src/providers/auth-login.test.ts index 92119f0..3665f18 100644 --- a/llm-client/src/providers/auth-login.test.ts +++ b/llm-client/src/providers/auth-login.test.ts @@ -46,6 +46,9 @@ function memoryStore(): AuthStore & { values: Map } { async set(id, auth) { values.set(id, auth); }, + async del(id) { + values.delete(id); + }, }; } 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..90fe67f --- /dev/null +++ b/llm-client/src/providers/auth-store.test.ts @@ -0,0 +1,59 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { 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("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 index 8c557e8..28260f4 100644 --- a/llm-client/src/providers/auth-store.ts +++ b/llm-client/src/providers/auth-store.ts @@ -19,6 +19,7 @@ export type ProviderAuth = OAuthAuth; export type AuthStore = { get(id: ProviderId): Promise; set(id: ProviderId, auth: ProviderAuth): Promise; + del(id: ProviderId): Promise; }; type AuthFile = { @@ -35,21 +36,39 @@ export function createFileAuthStore(path: string): AuthStore { } } + // 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()); + await mkdir(dirname(path), { recursive: true, mode: 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]; }, - async set(id, auth) { - const file = await readAll(); - const next: AuthFile = { + set(id, auth) { + return mutate((file) => ({ ...file, - providers: { - ...file.providers, - [id]: auth, - }, - }; - await mkdir(dirname(path), { recursive: true }); - await writeAtomic(path, new TextEncoder().encode(JSON.stringify(next))); + providers: { ...file.providers, [id]: auth }, + })); + }, + del(id) { + return mutate((file) => { + const providers = { ...file.providers }; + delete providers[id]; + return { ...file, providers }; + }); }, }; } diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts index 416d913..dde9a9d 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -38,7 +38,7 @@ function restoreProxyEnv( } function authStore(initial: ProviderAuth): AuthStore { - let value = initial; + let value: ProviderAuth | undefined = initial; return { async get(id) { expect(["codex", "copilot"]).toContain(id); @@ -47,6 +47,9 @@ function authStore(initial: ProviderAuth): AuthStore { async set(_id, next) { value = next; }, + async del(_id) { + value = undefined; + }, }; } diff --git a/llm-client/src/util.ts b/llm-client/src/util.ts index e3cb935..e694172 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -1,4 +1,4 @@ -import { rename } from "node:fs/promises"; +import { rename, chmod } from "node:fs/promises"; import { join } from "node:path"; /** @@ -18,14 +18,18 @@ export function sessionBlobPath(cacheDir: string, session_id: string): string { * 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. + * Caller must ensure the destination directory exists. Pass `mode` to fix the + * file's permission bits (applied to the temp file before the rename, so the + * target is never briefly world-readable) — used for credential files. */ export async function writeAtomic( path: string, data: Uint8Array, + opts?: { mode?: number }, ): Promise { const tmp = `${path}.tmp.${crypto.randomUUID()}`; await Bun.write(tmp, data); + if (opts?.mode !== undefined) await chmod(tmp, opts.mode); await rename(tmp, path); } From bd3470342679c52f9cba1f68bfdac28f4772b487 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 14:15:57 +0800 Subject: [PATCH 42/60] fix(client): make codex token refresh single-flight, validated, self-healing --- llm-client/src/providers/codex.ts | 178 +++++++++++++++++---- llm-client/src/providers/provider.test.ts | 180 +++++++++++++++++++++- 2 files changed, 328 insertions(+), 30 deletions(-) diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts index 4872ee0..3803f68 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -10,6 +10,13 @@ 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; @@ -19,12 +26,82 @@ type TokenResponse = { type JwtClaims = { chatgpt_account_id?: string; - organizations?: Array<{ 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; }; @@ -40,15 +117,17 @@ function parseJwtClaims(token: string): JwtClaims | 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 ?? - claims.organizations?.[0]?.id + claims["https://api.openai.com/auth"]?.chatgpt_account_id ); } export function extractCodexAccountId( - tokens: TokenResponse, + tokens: Pick, ): string | undefined { for (const token of [tokens.id_token, tokens.access_token]) { if (!token) continue; @@ -59,16 +138,31 @@ export function extractCodexAccountId( 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 { +): Promise { const response = await fetchWithProxy( opts.fetchImpl, `${ISSUER}/oauth/token`, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": userAgent(), + }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, @@ -77,38 +171,64 @@ async function refreshAccessToken( }, ); 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 (await response.json()) as TokenResponse; + return parseTokenResponse(await response.json(), refreshToken); } -async function currentAuth(opts: CodexProviderOpts): Promise { +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 auth = await opts.authStore.get("codex"); if (!auth || auth.type !== "oauth") { - throw new Error( - "Codex subscription is not logged in. Run `aimdware-router auth login codex` first.", - ); + throw new Error(`Codex subscription is not logged in. ${REAUTH_HINT}`); } - if (auth.access && auth.expires > now()) return auth; - - 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; + if (auth.access && auth.expires > now() + EXPIRY_SKEW_MS) return auth; + + // Single-flight: concurrent expired requests share one refresh, so a rotated + // refresh token is fetched and persisted exactly once. + if (gate.inFlight) return gate.inFlight; + gate.inFlight = (async () => { + try { + 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) { + // A terminal failure means the stored credential is dead — drop it so + // `auth status` reflects reality and the next request fails fast. + if (e instanceof CodexReauthRequiredError) { + await opts.authStore.del("codex"); + } + throw e; + } finally { + gate.inFlight = null; + } + })(); + return gate.inFlight; } function codexRequestBody(body: ArrayBuffer | undefined): RequestInit["body"] { @@ -127,10 +247,12 @@ function codexRequestBody(body: ArrayBuffer | undefined): RequestInit["body"] { } export function createCodexProvider(opts: CodexProviderOpts): ProviderRuntime { + const gate: RefreshGate = { inFlight: null }; + const prepareResponses = async ( input: Parameters[0], ) => { - const auth = await currentAuth(opts); + const auth = await currentAuth(opts, gate); const headers = new Headers(input.headers); headers.delete("authorization"); headers.delete("Authorization"); diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts index dde9a9d..89acd70 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -1,9 +1,40 @@ import { test, expect } from "bun:test"; import { proxyChat, proxyResponses, type FetchLike } from "../http/proxy"; -import { createCodexProvider } from "./codex"; +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", @@ -150,7 +181,7 @@ test("codex provider strips max_output_tokens before forwarding", async () => { type: "oauth", access: "access-token", refresh: "refresh-token", - expires: Date.now() + 60_000, + expires: Date.now() + 600_000, }); const upstreamBodies: string[] = []; const upstreamFetch: FetchLike = async (_input, init) => { @@ -286,3 +317,148 @@ test("copilot provider marks agent-initiated chat requests", async () => { 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 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("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(); +}); From ceae7e563fa58064d697dc1f1507f41b138a246f Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 14:16:13 +0800 Subject: [PATCH 43/60] fix(client): bound codex device-login polling and validate the token exchange --- llm-client/src/providers/auth-login.test.ts | 97 +++++++++++++++++++++ llm-client/src/providers/auth-login.ts | 26 ++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/llm-client/src/providers/auth-login.test.ts b/llm-client/src/providers/auth-login.test.ts index 3665f18..4e5147c 100644 --- a/llm-client/src/providers/auth-login.test.ts +++ b/llm-client/src/providers/auth-login.test.ts @@ -147,6 +147,103 @@ test("loginCodexDevice sends auth requests through HTTPS_PROXY", async () => { ]); }); +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[] = []; diff --git a/llm-client/src/providers/auth-login.ts b/llm-client/src/providers/auth-login.ts index 2b4eeb4..fe2ac8b 100644 --- a/llm-client/src/providers/auth-login.ts +++ b/llm-client/src/providers/auth-login.ts @@ -1,6 +1,6 @@ import type { FetchLike } from "../http/proxy"; import type { AuthStore, OAuthAuth } from "./auth-store"; -import { extractCodexAccountId } from "./codex"; +import { extractCodexAccountId, parseTokenResponse } from "./codex"; import { fetchWithProxy, userAgent } from "./plugin"; const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -16,12 +16,6 @@ type LoginOpts = { now?: () => number; }; -type CodexTokenResponse = { - access_token: string; - refresh_token: string; - expires_in?: number; -}; - function defaults(opts: LoginOpts): Required { return { fetchImpl: opts.fetchImpl ?? (fetch as unknown as FetchLike), @@ -58,12 +52,23 @@ export async function loginCodexDevice(opts: LoginOpts): Promise { 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`, @@ -90,7 +95,10 @@ export async function loginCodexDevice(opts: LoginOpts): Promise { `${CODEX_ISSUER}/oauth/token`, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": userAgent(), + }, body: new URLSearchParams({ grant_type: "authorization_code", code: code.authorization_code, @@ -103,7 +111,7 @@ export async function loginCodexDevice(opts: LoginOpts): Promise { if (!tokenResponse.ok) { throw new Error(`Codex token exchange failed: ${tokenResponse.status}`); } - const tokens = (await tokenResponse.json()) as CodexTokenResponse; + const tokens = parseTokenResponse(await tokenResponse.json()); const auth: OAuthAuth = { type: "oauth", access: tokens.access_token, From 4a2213037f3b49fd78f8d093a0fda4206c96b66b Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 15:19:37 +0800 Subject: [PATCH 44/60] fix(client): close codex refresh stale-read race in the single-flight gate --- llm-client/src/providers/codex.ts | 23 +++++++++---- llm-client/src/providers/provider.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts index 3803f68..7053d8f 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -191,18 +191,29 @@ async function currentAuth( const now = opts.now ?? Date.now; const fetchImpl = opts.fetchImpl ?? (fetch as unknown as ProviderFetchOpts["fetchImpl"]); - const auth = await opts.authStore.get("codex"); - if (!auth || auth.type !== "oauth") { - throw new Error(`Codex subscription is not logged in. ${REAUTH_HINT}`); - } + 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; - if (auth.access && auth.expires > now() + EXPIRY_SKEW_MS) return auth; + 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. + // 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; gate.inFlight = (async () => { try { + const auth = await readAuth(); + if (isFresh(auth)) return auth; const tokens = await refreshAccessToken(auth.refresh, { now, fetchImpl: fetchImpl!, diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts index 89acd70..5110e70 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -343,6 +343,48 @@ test("codex refresh is single-flight under concurrent expired requests", async ( 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", From 98da0e39bdac91d7bbb23f7b70d42fcf276c4a1a Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 15:21:04 +0800 Subject: [PATCH 45/60] fix(client): close auth.json permission gaps (temp-file window, existing dir) --- llm-client/src/providers/auth-store.test.ts | 13 ++++++++++++- llm-client/src/providers/auth-store.ts | 9 +++++++-- llm-client/src/util.ts | 16 ++++++++++------ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/llm-client/src/providers/auth-store.test.ts b/llm-client/src/providers/auth-store.test.ts index 90fe67f..783c3bd 100644 --- a/llm-client/src/providers/auth-store.test.ts +++ b/llm-client/src/providers/auth-store.test.ts @@ -1,5 +1,5 @@ import { test, expect, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createFileAuthStore } from "./auth-store"; @@ -46,6 +46,17 @@ test("auth.json is written owner-only (0600) inside a 0700 directory", async () 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("concurrent set calls do not lose provider entries", async () => { const store = createFileAuthStore(freshAuthPath()); diff --git a/llm-client/src/providers/auth-store.ts b/llm-client/src/providers/auth-store.ts index 28260f4..d2b83f0 100644 --- a/llm-client/src/providers/auth-store.ts +++ b/llm-client/src/providers/auth-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile } from "node:fs/promises"; +import { chmod, mkdir, readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { writeAtomic } from "../util"; import type { ProviderId } from "./plugin"; @@ -44,7 +44,12 @@ export function createFileAuthStore(path: string): AuthStore { function mutate(transform: (file: AuthFile) => AuthFile): Promise { const run = writeChain.then(async () => { const next = transform(await readAll()); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + 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, }); diff --git a/llm-client/src/util.ts b/llm-client/src/util.ts index e694172..b9d280d 100644 --- a/llm-client/src/util.ts +++ b/llm-client/src/util.ts @@ -1,4 +1,4 @@ -import { rename, chmod } from "node:fs/promises"; +import { rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; /** @@ -18,9 +18,10 @@ export function sessionBlobPath(cacheDir: string, session_id: string): string { * 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 fix the - * file's permission bits (applied to the temp file before the rename, so the - * target is never briefly world-readable) — used for credential files. + * 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, @@ -28,8 +29,11 @@ export async function writeAtomic( opts?: { mode?: number }, ): Promise { const tmp = `${path}.tmp.${crypto.randomUUID()}`; - await Bun.write(tmp, data); - if (opts?.mode !== undefined) await chmod(tmp, opts.mode); + if (opts?.mode !== undefined) { + await writeFile(tmp, data, { mode: opts.mode }); + } else { + await Bun.write(tmp, data); + } await rename(tmp, path); } From 08c1eb88023f5aabed1ff47513809b3109984ada Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 15:30:28 +0800 Subject: [PATCH 46/60] fix(client): isolate credential dir so chmod can't clobber the shared cache --- llm-client/src/main.ts | 6 +++--- llm-client/src/providers/auth-store.test.ts | 20 ++++++++++++++++++-- llm-client/src/providers/auth-store.ts | 13 ++++++++++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/llm-client/src/main.ts b/llm-client/src/main.ts index 8732c2c..01216ce 100644 --- a/llm-client/src/main.ts +++ b/llm-client/src/main.ts @@ -6,7 +6,7 @@ import { homedir } from "node:os"; import { loadConfig, type Config } from "./config"; import { createHandler } from "./http/handler"; import { startServer } from "./http/server"; -import { createFileAuthStore } from "./providers/auth-store"; +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"; @@ -211,7 +211,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); const cacheDir = expandHome(config.local_cache_dir); await mkdir(join(cacheDir, "records"), { recursive: true }); const queueDb = join(cacheDir, "queue.db"); - const authStore = createFileAuthStore(join(cacheDir, "auth.json")); + const authStore = createFileAuthStore(authFilePath(cacheDir)); try { if ( @@ -329,7 +329,7 @@ expected fields (student_token, course, backend_url, tbox_*, upstream).`); console.log(` upstream url: ${config.upstream.base_url}`); console.log(` upstream key: ${redactToken(config.upstream.api_key)}`); } else { - console.log(` upstream auth: ${join(cacheDir, "auth.json")}`); + console.log(` upstream auth: ${authFilePath(cacheDir)}`); } console.log(` student token: ${redactToken(config.student_token)}`); console.log(` course: ${config.course}`); diff --git a/llm-client/src/providers/auth-store.test.ts b/llm-client/src/providers/auth-store.test.ts index 783c3bd..fb8ea1d 100644 --- a/llm-client/src/providers/auth-store.test.ts +++ b/llm-client/src/providers/auth-store.test.ts @@ -1,8 +1,8 @@ import { test, expect, afterEach } from "bun:test"; -import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { createFileAuthStore } from "./auth-store"; +import { authFilePath, createFileAuthStore } from "./auth-store"; const tmpDirs: string[] = []; function freshAuthPath(): string { @@ -57,6 +57,22 @@ test("tightens an already-existing parent directory to 0700", async () => { 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("concurrent set calls do not lose provider entries", async () => { const store = createFileAuthStore(freshAuthPath()); diff --git a/llm-client/src/providers/auth-store.ts b/llm-client/src/providers/auth-store.ts index d2b83f0..080ca3b 100644 --- a/llm-client/src/providers/auth-store.ts +++ b/llm-client/src/providers/auth-store.ts @@ -1,5 +1,5 @@ import { chmod, mkdir, readFile } from "node:fs/promises"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { writeAtomic } from "../util"; import type { ProviderId } from "./plugin"; @@ -26,6 +26,17 @@ 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): AuthStore { async function readAll(): Promise { try { From 94bc090f60bb01fe940ff74b50c4bc2ad8be9161 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 15:48:32 +0800 Subject: [PATCH 47/60] fix(client): recover instead of deleting when a concurrent refresh rotated the token --- llm-client/src/providers/codex.ts | 27 +++++++++++++-- llm-client/src/providers/provider.test.ts | 40 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/llm-client/src/providers/codex.ts b/llm-client/src/providers/codex.ts index 7053d8f..1ca0076 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -211,9 +211,11 @@ async function currentAuth( // with the now-invalid one. if (gate.inFlight) return gate.inFlight; gate.inFlight = (async () => { + let usedRefresh = ""; try { const auth = await readAuth(); if (isFresh(auth)) return auth; + usedRefresh = auth.refresh; const tokens = await refreshAccessToken(auth.refresh, { now, fetchImpl: fetchImpl!, @@ -229,10 +231,29 @@ async function currentAuth( await opts.authStore.set("codex", next); return next; } catch (e) { - // A terminal failure means the stored credential is dead — drop it so - // `auth status` reflects reality and the next request fails fast. if (e instanceof CodexReauthRequiredError) { - await opts.authStore.del("codex"); + // 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; } finally { diff --git a/llm-client/src/providers/provider.test.ts b/llm-client/src/providers/provider.test.ts index 5110e70..c159b0e 100644 --- a/llm-client/src/providers/provider.test.ts +++ b/llm-client/src/providers/provider.test.ts @@ -489,6 +489,46 @@ test("codex treats a 401 refresh as terminal re-login", async () => { ).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({ From 84d3f08facf8e4dea01adb3dfddf6aa8c65e0203 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 15:54:05 +0800 Subject: [PATCH 48/60] fix(client): serialize codex refresh across processes with a credential file lock --- llm-client/src/providers/auth-store.test.ts | 49 +++++++++++++++++++- llm-client/src/providers/auth-store.ts | 50 ++++++++++++++++++++- llm-client/src/providers/codex.ts | 14 +++++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/llm-client/src/providers/auth-store.test.ts b/llm-client/src/providers/auth-store.test.ts index fb8ea1d..d339c81 100644 --- a/llm-client/src/providers/auth-store.test.ts +++ b/llm-client/src/providers/auth-store.test.ts @@ -1,5 +1,13 @@ import { test, expect, afterEach } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +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"; @@ -73,6 +81,45 @@ test("does not alter the shared cache directory that holds other state", async ( 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()); diff --git a/llm-client/src/providers/auth-store.ts b/llm-client/src/providers/auth-store.ts index 080ca3b..7597d10 100644 --- a/llm-client/src/providers/auth-store.ts +++ b/llm-client/src/providers/auth-store.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readFile } from "node:fs/promises"; +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"; @@ -20,6 +20,11 @@ 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 = { @@ -37,7 +42,14 @@ export function authFilePath(cacheDir: string): string { return join(cacheDir, "auth", "auth.json"); } -export function createFileAuthStore(path: string): AuthStore { +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; @@ -86,5 +98,39 @@ export function createFileAuthStore(path: string): AuthStore { 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 index 1ca0076..2052353 100644 --- a/llm-client/src/providers/codex.ts +++ b/llm-client/src/providers/codex.ts @@ -210,7 +210,14 @@ async function currentAuth( // refresh completed must pick up the rotated token rather than refresh again // with the now-invalid one. if (gate.inFlight) return gate.inFlight; - gate.inFlight = (async () => { + // 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(); @@ -256,6 +263,11 @@ async function currentAuth( } } throw e; + } + }; + gate.inFlight = (async () => { + try { + return await withLock(refresh); } finally { gate.inFlight = null; } From 7c50f1dc0f189824615cc82bfeeea31277e780c5 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Tue, 2 Jun 2026 18:34:42 +0800 Subject: [PATCH 49/60] ci(client): add [lock]-triggered cross-process refresh concurrency test --- .gitea/workflows/concurrency.yaml | 32 +++++++ test-scripts/concurrency/child.ts | 48 ++++++++++ test-scripts/concurrency/run.ts | 154 ++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 .gitea/workflows/concurrency.yaml create mode 100644 test-scripts/concurrency/child.ts create mode 100644 test-scripts/concurrency/run.ts 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/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"); From 5c27a85df1b849246d5d42aa76854f7e38e6af6d Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 8 Jun 2026 17:05:58 +0800 Subject: [PATCH 50/60] chore(backend): load .env --- backend/.gitignore | 1 + backend/src/aimdware_backend/settings.py | 10 ++++++- backend/tests/test_settings.py | 35 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_settings.py diff --git a/backend/.gitignore b/backend/.gitignore index 0bd6d6a..cc2a094 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ .venv/ +.env __pycache__/ *.egg-info/ .pytest_cache/ diff --git a/backend/src/aimdware_backend/settings.py b/backend/src/aimdware_backend/settings.py index 4ed12b2..7c4c230 100644 --- a/backend/src/aimdware_backend/settings.py +++ b/backend/src/aimdware_backend/settings.py @@ -6,7 +6,15 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="AIMDWARE_", case_sensitive=False) + # 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" 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" From 04c8ec1bc70e9df14439829c53c8d633a19e4dc4 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sun, 21 Jun 2026 16:52:28 +0800 Subject: [PATCH 51/60] docs: update tt and student side docs --- .gitignore | 1 + wiki/llm-client.md | 6 +- wiki/student-setup.md | 313 ++++++++++++++++++++++++++++++++++++++++++ wiki/ta-deployment.md | 292 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 wiki/student-setup.md create mode 100644 wiki/ta-deployment.md diff --git a/.gitignore b/.gitignore index be257df..e50d535 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ test-functional/opencode.json # Personal runbook — do not commit AGENT.md +.claude/ diff --git a/wiki/llm-client.md b/wiki/llm-client.md index 31fda50..d0ba38c 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -48,8 +48,8 @@ upstream: plugin: codex # or copilot ``` -The subscription tokens are stored in `local_cache_dir/auth.json`, not -in `aimdware.yaml`. +The subscription tokens are stored in `local_cache_dir/auth/auth.json` +(a dedicated 0700 dir, file 0600), not in `aimdware.yaml`. ## API surface @@ -73,7 +73,7 @@ Responses endpoint. |---|---|---| | `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.json`, only for `plugin: codex/copilot` | call that student's subscription 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 | diff --git a/wiki/student-setup.md b/wiki/student-setup.md new file mode 100644 index 0000000..72c528d --- /dev/null +++ b/wiki/student-setup.md @@ -0,0 +1,313 @@ +# 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`). + +### 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..be38684 --- /dev/null +++ b/wiki/ta-deployment.md @@ -0,0 +1,292 @@ +# 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; +``` + +```bash +export AIMDWARE_DATABASE_URL='postgresql://aimdware:...@localhost:5432/aimdware' +``` + +> Use a driver SQLAlchemy understands. `postgresql://…` uses the default +> driver; install/select another (e.g. `postgresql+psycopg://…`) if your +> environment needs it. + +--- + +## 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`. + +> **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 | From 6f938a19c9c32e3688c4e23d12fee3ab642a96ad Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Sun, 21 Jun 2026 23:54:04 +0800 Subject: [PATCH 52/60] docs(spec): aimdware-admin CSV batch design --- .../2026-06-21-admin-csv-batch-design.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md diff --git a/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md b/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md new file mode 100644 index 0000000..a06c525 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md @@ -0,0 +1,60 @@ +# aimdware-admin CSV batch — design + +## Goal +Let TT manage a whole roster in one shot. `user create`, `enroll`, `token issue`, +`token revoke` each gain a `--csv` mode that operates over a roster file, while +keeping their existing single-item forms. + +## Roster CSV +- UTF-8 (BOM tolerated), column order `name,student_id,jaccount` (Chinese + header `名字,学号,jaccount` allowed). +- A header row is auto-detected and skipped; blank lines skipped. +- Per row only `jaccount` is required; `student_id` may be empty. `name` is + required for `user create` (the others ignore it). + +## Data model +- `User` gains `student_id: str | None = None` — nullable, **not unique** + (rosters may carry blanks/dupes; we don't want import to fail on it). +- `email` is now derivable: when not supplied, use `@`, + default domain `sjtu.edu.cn`, overridable with `--email-domain`. +- Alembic migration `0004_add_student_id`: add nullable `users.student_id`. + +## Commands (single-item form preserved; `--csv` is mutually exclusive with it) +| Command | CSV form | Per-row behavior | +|---|---|---| +| `user create` | `--csv FILE [--email-domain D]` | create user (display_name=name, student_id, email derived); existing jaccount → `exists` | +| `enroll` | `--csv FILE --course C [--role student]` | enroll jaccount in C; already enrolled → `exists` | +| `token issue` | `--csv FILE` | issue (rotates) per jaccount; result carries `plaintext`+`prefix` | +| `token revoke` | `--csv FILE` | revoke ALL active tokens for that jaccount; result carries revoked count | + +Single forms: `user create` gains optional `--email` (derived if omitted) and +`--student-id`; `token revoke --prefix` is unchanged. + +## Batch semantics +- Process rows independently, **continue on error**. +- Print a JSON array of `{jaccount, status, ...}` (status ∈ + created/exists/enrolled/issued/revoked/error; error rows carry `error`). +- Exit non-zero if any row errored (so scripts can detect partial failure). + +## Internal structure +Extract the core of each operation into a function taking a `Session` +(`create_user`, `enroll_user`, `issue_token` already exists, `revoke_tokens_for_user`). +Single and CSV paths both call these — no duplicated logic. Roster parsing lives +in `roster.py` (`read_roster(path) -> list[RosterRow]`) so it is unit-testable. + +## Tests (TDD) +- `roster.read_roster`: 3-col UTF-8 parse, header/blank skip, jaccount required. +- `user create --csv`: derived email + stored student_id; existing jaccount → exists, no crash. +- `user create` single: email derived when omitted; `--student-id` stored. +- `enroll --csv`: enroll all; already-enrolled → exists. +- `token issue --csv`: per-jaccount issue, output has plaintext+prefix, rotates. +- `token revoke --csv`: revokes all active per user; count. +- model: `student_id` defaults None / nullable. + +## Docs +Update `wiki/ta-deployment.md` and `wiki/admin-script.md` with the `--csv` forms. + +## Out of scope +- `enrollment list --course` (separate ask). +- Unique constraint / validation on student_id. +- Writing tokens to a CSV file directly (JSON array output is enough; TT can format). From 64072c422ec5a9922f1d266a5a8cb2330d10f254 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 22 Jun 2026 00:01:59 +0800 Subject: [PATCH 53/60] feat(admin_cli): CSV roster batch for user create / enroll / token issue & revoke --- .../c0a1d2e3f4b5_0004_add_student_id.py | 33 +++ backend/src/aimdware_backend/admin_cli.py | 217 ++++++++++++++++-- backend/src/aimdware_backend/models.py | 4 + backend/src/aimdware_backend/roster.py | 42 ++++ backend/tests/test_admin_cli.py | 102 ++++++++ backend/tests/test_roster.py | 48 ++++ wiki/admin-script.md | 29 +++ wiki/ta-deployment.md | 12 + 8 files changed, 463 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/c0a1d2e3f4b5_0004_add_student_id.py create mode 100644 backend/src/aimdware_backend/roster.py create mode 100644 backend/tests/test_roster.py 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/src/aimdware_backend/admin_cli.py b/backend/src/aimdware_backend/admin_cli.py index 8320bf7..849dd2f 100644 --- a/backend/src/aimdware_backend/admin_cli.py +++ b/backend/src/aimdware_backend/admin_cli.py @@ -39,13 +39,34 @@ User, utcnow, ) +from aimdware_backend.roster import RosterRow, read_roster # --- importable command functions (also covered by tests) --------------- -def user_create(session: Session, *, jaccount: str, email: str, display_name: str) -> User: +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) + user = User( + jaccount=jaccount, + email=email, + display_name=display_name, + student_id=student_id, + ) session.add(user) session.commit() session.refresh(user) @@ -140,6 +161,109 @@ def token_revoke(session: Session, *, prefix: str) -> int: 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 ----------------------------------------------------- @@ -147,17 +271,40 @@ 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) -> None: +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=args.email, + email=email, display_name=args.name, + student_id=args.student_id, ) - _print_json({"id": str(u.id), "jaccount": u.jaccount, "email": u.email}) + _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: @@ -195,13 +342,16 @@ def _cmd_course_list(session: Session, _args: argparse.Namespace) -> None: ) -def _cmd_enroll(session: Session, args: argparse.Namespace) -> None: - e = enroll( - session, - jaccount=args.user, - course_code=args.course, - role=Role(args.role), - ) +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), @@ -209,17 +359,30 @@ def _cmd_enroll(session: Session, args: argparse.Namespace) -> None: "role": e.role, } ) + return 0 -def _cmd_token_issue(session: Session, args: argparse.Namespace) -> None: +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) -> None: +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: @@ -320,9 +483,12 @@ def build_parser() -> argparse.ArgumentParser: 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", required=True) - p_uc.add_argument("--email", required=True) - p_uc.add_argument("--name", required=True) + 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) @@ -338,19 +504,22 @@ def build_parser() -> argparse.ArgumentParser: # enroll p_enr = sub.add_parser("enroll", help="enroll a user in a course") - p_enr.add_argument("--user", required=True, help="user jaccount") + 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", required=True, help="user jaccount") + 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", required=True, help="8-char prefix shown when issued") + 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) @@ -396,13 +565,13 @@ def main(argv: list[str] | None = None) -> int: with Session(engine) as session: if inspect.iscoroutinefunction(args.func): - asyncio.run(args.func(session, args)) + rc = asyncio.run(args.func(session, args)) else: - args.func(session, args) - return 0 + rc = args.func(session, args) + return rc if isinstance(rc, int) else 0 -CURRENT_SCHEMA_REVISION = "b984da6ac5c5" +CURRENT_SCHEMA_REVISION = "c0a1d2e3f4b5" def _schema_exists(engine) -> bool: # type: ignore[no-untyped-def] diff --git a/backend/src/aimdware_backend/models.py b/backend/src/aimdware_backend/models.py index 7eca5a0..f9461a7 100644 --- a/backend/src/aimdware_backend/models.py +++ b/backend/src/aimdware_backend/models.py @@ -47,6 +47,10 @@ class User(SQLModel, table=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) 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/tests/test_admin_cli.py b/backend/tests/test_admin_cli.py index 96eecf0..8d21760 100644 --- a/backend/tests/test_admin_cli.py +++ b/backend/tests/test_admin_cli.py @@ -10,14 +10,21 @@ 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: @@ -131,3 +138,98 @@ def test_schema_probe_rejects_legacy_create_all_schema() -> None: 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_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/wiki/admin-script.md b/wiki/admin-script.md index 8674cc7..1bb6d6d 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -22,6 +22,7 @@ the backend host. Course-scoped admin role enforcement is deferred. ``` 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 @@ -37,6 +38,34 @@ aimdware-admin record list [--course X] [--user X] [--assignment X] [--status 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 +``` + 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 diff --git a/wiki/ta-deployment.md b/wiki/ta-deployment.md index be38684..9508e4d 100644 --- a/wiki/ta-deployment.md +++ b/wiki/ta-deployment.md @@ -202,6 +202,18 @@ uv run aimdware-admin token issue --user 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 From 1a5249965af3996757da735efb6a031e5df73785 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 22 Jun 2026 20:59:43 +0800 Subject: [PATCH 54/60] docs(wiki): student_id schema + codex ChatGPT-account specifics --- wiki/backend.md | 10 +++++++++- wiki/llm-client.md | 19 +++++++++++++++++++ wiki/student-setup.md | 10 +++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/wiki/backend.md b/wiki/backend.md index f87cd83..4af3c33 100644 --- a/wiki/backend.md +++ b/wiki/backend.md @@ -80,7 +80,9 @@ Response shape (both endpoints): SQLModel-declared, Alembic-migrated. Schema diagram: ``` -users(id, jaccount UNIQUE, email UNIQUE, display_name, is_active, ts) +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) @@ -170,8 +172,14 @@ 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) ``` +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 diff --git a/wiki/llm-client.md b/wiki/llm-client.md index d0ba38c..3c67a03 100644 --- a/wiki/llm-client.md +++ b/wiki/llm-client.md @@ -67,6 +67,25 @@ 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 | diff --git a/wiki/student-setup.md b/wiki/student-setup.md index 72c528d..c2a06c9 100644 --- a/wiki/student-setup.md +++ b/wiki/student-setup.md @@ -146,7 +146,15 @@ upstream: ``` Codex is a **Responses-only** provider — point clients at -`/v1/responses` (not `/v1/chat/completions`). +`/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 From 468848a6fa36990a4422be2ffdb4c86e50d36c45 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 22 Jun 2026 21:05:23 +0800 Subject: [PATCH 55/60] =?UTF-8?q?docs:=20accuracy=20pass=20=E2=80=94=20CSV?= =?UTF-8?q?=20batch=20+=20subscriptions=20shipped,=20WebDAV=20PUT,=20pg=20?= =?UTF-8?q?driver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- llm-client/.gitignore | 1 + wiki/admin-script.md | 4 +++- wiki/design-notes.md | 1 - wiki/ta-deployment.md | 11 +++++++---- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 49b33ca..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 diff --git a/llm-client/.gitignore b/llm-client/.gitignore index dd6e803..cdec036 100644 --- a/llm-client/.gitignore +++ b/llm-client/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.log .DS_Store +aimdware.yaml diff --git a/wiki/admin-script.md b/wiki/admin-script.md index 1bb6d6d..ae1a523 100644 --- a/wiki/admin-script.md +++ b/wiki/admin-script.md @@ -118,6 +118,8 @@ separate command (TODO). ## Deferred - Course-scoped admin authority (filter commands by `--as `) -- `enroll bulk --csv roster.csv` - `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/design-notes.md b/wiki/design-notes.md index 2534f0c..3ae0901 100644 --- a/wiki/design-notes.md +++ b/wiki/design-notes.md @@ -173,7 +173,6 @@ For "did this student have filesystem access tools": - **Per-course admin authorization in `/admin/*`.** v1 is a shared secret with global read. -- **Codex / Copilot OAuth upstreams.** Only OpenAI-key style v1. - **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. diff --git a/wiki/ta-deployment.md b/wiki/ta-deployment.md index 9508e4d..ead963f 100644 --- a/wiki/ta-deployment.md +++ b/wiki/ta-deployment.md @@ -68,13 +68,16 @@ 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 -export AIMDWARE_DATABASE_URL='postgresql://aimdware:...@localhost:5432/aimdware' +uv add "psycopg[binary]" +export AIMDWARE_DATABASE_URL='postgresql+psycopg://aimdware:...@localhost:5432/aimdware' ``` -> Use a driver SQLAlchemy understands. `postgresql://…` uses the default -> driver; install/select another (e.g. `postgresql+psycopg://…`) if your -> environment needs it. +> (SQLite — the default `sqlite:///./aimdware.db` — needs no extra driver, for +> a quick local trial only.) --- From 19e23e7a1f9d1988a06635dd8f2aff6d878c9d83 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 22 Jun 2026 21:21:45 +0800 Subject: [PATCH 56/60] chore: rm outdated docs --- .../2026-06-21-admin-csv-batch-design.md | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md diff --git a/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md b/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md deleted file mode 100644 index a06c525..0000000 --- a/docs/superpowers/specs/2026-06-21-admin-csv-batch-design.md +++ /dev/null @@ -1,60 +0,0 @@ -# aimdware-admin CSV batch — design - -## Goal -Let TT manage a whole roster in one shot. `user create`, `enroll`, `token issue`, -`token revoke` each gain a `--csv` mode that operates over a roster file, while -keeping their existing single-item forms. - -## Roster CSV -- UTF-8 (BOM tolerated), column order `name,student_id,jaccount` (Chinese - header `名字,学号,jaccount` allowed). -- A header row is auto-detected and skipped; blank lines skipped. -- Per row only `jaccount` is required; `student_id` may be empty. `name` is - required for `user create` (the others ignore it). - -## Data model -- `User` gains `student_id: str | None = None` — nullable, **not unique** - (rosters may carry blanks/dupes; we don't want import to fail on it). -- `email` is now derivable: when not supplied, use `@`, - default domain `sjtu.edu.cn`, overridable with `--email-domain`. -- Alembic migration `0004_add_student_id`: add nullable `users.student_id`. - -## Commands (single-item form preserved; `--csv` is mutually exclusive with it) -| Command | CSV form | Per-row behavior | -|---|---|---| -| `user create` | `--csv FILE [--email-domain D]` | create user (display_name=name, student_id, email derived); existing jaccount → `exists` | -| `enroll` | `--csv FILE --course C [--role student]` | enroll jaccount in C; already enrolled → `exists` | -| `token issue` | `--csv FILE` | issue (rotates) per jaccount; result carries `plaintext`+`prefix` | -| `token revoke` | `--csv FILE` | revoke ALL active tokens for that jaccount; result carries revoked count | - -Single forms: `user create` gains optional `--email` (derived if omitted) and -`--student-id`; `token revoke --prefix` is unchanged. - -## Batch semantics -- Process rows independently, **continue on error**. -- Print a JSON array of `{jaccount, status, ...}` (status ∈ - created/exists/enrolled/issued/revoked/error; error rows carry `error`). -- Exit non-zero if any row errored (so scripts can detect partial failure). - -## Internal structure -Extract the core of each operation into a function taking a `Session` -(`create_user`, `enroll_user`, `issue_token` already exists, `revoke_tokens_for_user`). -Single and CSV paths both call these — no duplicated logic. Roster parsing lives -in `roster.py` (`read_roster(path) -> list[RosterRow]`) so it is unit-testable. - -## Tests (TDD) -- `roster.read_roster`: 3-col UTF-8 parse, header/blank skip, jaccount required. -- `user create --csv`: derived email + stored student_id; existing jaccount → exists, no crash. -- `user create` single: email derived when omitted; `--student-id` stored. -- `enroll --csv`: enroll all; already-enrolled → exists. -- `token issue --csv`: per-jaccount issue, output has plaintext+prefix, rotates. -- `token revoke --csv`: revokes all active per user; count. -- model: `student_id` defaults None / nullable. - -## Docs -Update `wiki/ta-deployment.md` and `wiki/admin-script.md` with the `--csv` forms. - -## Out of scope -- `enrollment list --course` (separate ask). -- Unique constraint / validation on student_id. -- Writing tokens to a CSV file directly (JSON array output is enough; TT can format). From a1dfd02e3840e94d19546fbdc004c612ca1acd42 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 6 Jul 2026 16:30:23 +0800 Subject: [PATCH 57/60] chore: ignore env --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e50d535..da97dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ test-functional/opencode.json # Personal runbook — do not commit AGENT.md .claude/ +.env From 10d487db72e6d4bd7b4644ebb3540d6debcb485f Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 6 Jul 2026 17:56:09 +0800 Subject: [PATCH 58/60] Add GitHub client build workflow --- .github/workflows/client-build.yml | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/client-build.yml diff --git a/.github/workflows/client-build.yml b/.github/workflows/client-build.yml new file mode 100644 index 0000000..5444f57 --- /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-latest + target: macos-arm64 + script: build:macos-arm64 + artifact: aimdware-router-macos-arm64 + - os: macos-latest + 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@v4 + + - 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@v4 + with: + name: ${{ matrix.artifact }} + path: llm-client/dist/${{ matrix.artifact }} + if-no-files-found: error From 9d45500c4de53a20a3a45115b5034e7b2acce693 Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 6 Jul 2026 17:58:39 +0800 Subject: [PATCH 59/60] Update GitHub Actions versions --- .github/workflows/client-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/client-build.yml b/.github/workflows/client-build.yml index 5444f57..4b9590e 100644 --- a/.github/workflows/client-build.yml +++ b/.github/workflows/client-build.yml @@ -35,7 +35,7 @@ jobs: working-directory: llm-client steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 @@ -47,7 +47,7 @@ jobs: - run: bun run ${{ matrix.script }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: llm-client/dist/${{ matrix.artifact }} From e8a9b5d81e36d81a8fac8d42973740327f9b396a Mon Sep 17 00:00:00 2001 From: zzjc1234 <2359047351@qq.com> Date: Mon, 6 Jul 2026 17:59:41 +0800 Subject: [PATCH 60/60] Pin macOS Actions runner --- .github/workflows/client-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/client-build.yml b/.github/workflows/client-build.yml index 4b9590e..4d08536 100644 --- a/.github/workflows/client-build.yml +++ b/.github/workflows/client-build.yml @@ -13,11 +13,11 @@ jobs: fail-fast: false matrix: include: - - os: macos-latest + - os: macos-15 target: macos-arm64 script: build:macos-arm64 artifact: aimdware-router-macos-arm64 - - os: macos-latest + - os: macos-15 target: macos-x64 script: build:macos-x64 artifact: aimdware-router-macos-x64