diff --git a/AGENTS.md b/AGENTS.md index 818146aa..6d90f076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,8 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → `SseHub` fans them out over SSE to the browser. The separate PostgreSQL catalog stores database metadata and sequential AI description-generation runs. Description generation samples the DWH through read-only connectors and calls a short-lived Python LiteLLM helper; it does not use Pi or - expose a public CLI command. App settings still live in `backend/data/settings.json`. + expose a public CLI command. Sessions, metadata generation, and embedding resolve models from the + generated Installation Model Catalog; `thothii-installation.yaml` is its only authored source. - **Human-in-the-loop gate contract.** The model proposes; a human reviewer decides at gates via widgets (`reviewer_select` = single pick — a chosen option carrying a `decision` payload @@ -99,11 +100,11 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → - **`--json` output must be pristine** (only valid JSON on stdout) — used as a machine contract. - **UI strings are English; document *content* stays the workspace language** (Italian for `psd`) because it's the real data. Only chrome/labels are English. -- **Workspaces** (`harness/workspaces/*.yaml`) set the DB target and **absolute** - `paths.sessions/artifacts/indexes` — for `psd` these point at a *separate, uncommitted* repo - (`tht-workspace-psd/`). Secrets live ONLY in `harness/.env` (gitignored). -- **Settings are global** (`backend/data/settings.json`: workspace/provider/model/thinking); - the New-session form is question-only. +- **Workspace schema v4** defines database and Evidence concerns only. Embedding/model facts come + from the installation catalog. The legacy `harness/workspaces/*.yaml` runtime snapshots still use + absolute session/artifact/index paths; secrets stay in `harness/.env` (gitignored). +- **Settings are global** (`backend/data/settings.json`: workspace/thinking). Provider/model choices + are ephemeral canonical catalog selections pinned into the session manifest. - **Resume**: a resumable session re-enters at its last incomplete phase. The backend refuses resume with 409 when `finalized` or `archived`, and `PiProcessManager.spawnFor` must send `/riprendi-sessione ` (resume mode) vs `/nuova-domanda` (new) — sending the wrong prompt diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index cecb114d..4e62d502 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -Last updated: 2026-08-31. +Last updated: 2026-09-02. This file is the short operational snapshot. Stable commands and the architecture mental model live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`, @@ -59,10 +59,28 @@ tht --installation /absolute/path/thothii-installation.yaml workspace preprocess These commands use the profile-gated `workspace-maintenance` service. The former standalone preprocessing Compose fixtures are retired. -Workspace descriptors use schema v3. For PSD, workspace content and runtime roots point to the +Workspace descriptors use schema v4 and contain only database, Evidence, diagnostics, and binding +concerns; model, provider, embedding, and vector-store configuration is installation-owned. For +PSD, workspace content and runtime roots point to the separate uncommitted repository `/Users/mp/projects/tht-workspace-psd`. Secrets remain outside Git and are supplied only through installation-local protected files. +## Installation Model Catalog + +`thothii-installation.yaml` schema version 2 is the only operator-authored source for session, +metadata-generation, and embedding models. The host `tht` lifecycle validates `modelCatalog` and +regenerates the backend catalog, Pi `models.json`/`settings.json`, and Compose override under the +installation-local `generated/` directory. Those projections are replaceable runtime adapters: +they are not edited, backed up, or treated as configuration. + +Session and metadata defaults use canonical `provider/model` IDs. Provider authentication declares +one explicit mode (`secret_env`, `pi_auth`, or `none`); `secret_env` names a protected bundle key. +The backend settings store now owns only the selected workspace and thinking level. Existing v1 +installations use the explicit catalog migration command; schema-v3 workspace descriptors are +converted deterministically in their curator-owned repository before commit. Strict runtime loading +does not silently infer or merge legacy sources. ADR 0013 and +`docs/plans/2026-09-02-installation-model-catalog.md` record the decision and implementation. + ## Database management The database, table, and authoritative physical-schema catalog slices are implemented. Database @@ -164,7 +182,8 @@ available only when no local start, worker, or helper is live. Runs remain inspe live SSE log with ordered polling fallback; there is no automatic resume or user-facing generation CLI. ADRs 0009–0010 record the runtime and source-sampling decisions. -Metadata-generation setup accepts the protected `DEEPSEEK_API_KEY` and `ZAI_API_KEY` references. +The Installation Model Catalog accepts the protected `DEEPSEEK_API_KEY` and `ZAI_API_KEY` +references for metadata-generation providers. It also accepts a model with no secret reference only when its OpenAI-compatible endpoint is explicit; this covers the VPN-only AritmoLab Qwen 3.6 server without creating a fake operator credential. The Python client supplies only its fixed non-secret compatibility placeholder. diff --git a/README.md b/README.md index 7f81b54e..102419f8 100644 --- a/README.md +++ b/README.md @@ -106,15 +106,18 @@ a remote user's partial list. The isolated deployment exercise is `./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`. -Schema v3 is the only accepted workspace descriptor. Schema v1 and v2 workspace descriptors are -rejected before activation. Candidate snapshot validation therefore makes activation or a pull fail -atomically while the prior valid snapshot remains active. There is no in-product migrator or -automatic conversion. A repository must already contain reviewed v3 descriptors. One workspace -owns one Qdrant collection; +Schema v4 is the only accepted workspace descriptor. Schema v1, v2, and v3 workspace descriptors +are rejected before activation. Candidate snapshot validation therefore makes activation or a pull +fail atomically while the prior valid snapshot remains active. One workspace owns one Qdrant collection; schema, Evidence, and Memory records share that collection and stay separated by indexed payload `kind`. + +Convert a v3 descriptor before publication by setting `workspace.schema_version` to `4` and +removing `llm_policy` and `semantic_index`; no database or Evidence field changes. + + For NL→SQL runtime sessions, connector `ssh_tunnel` bindings remain diagnostic-only: their bounded probe cleans up the loopback forward and returns `workspace_not_activatable`; session creation is rejected before persistence. Database management is a separate boundary and supports a strict @@ -176,10 +179,10 @@ secret files, upstream-auth checks, and a fail-closed `503` assertion for its de unavailable disposable session endpoint. No real provider, database credential, or repository secret is required. -For a clean server bind, `scripts/prepare-server-pi-state.sh` creates the hidden regular -`agent/auth.json`, `agent/models.json`, and `agent/settings.json` mount targets atomically before -Compose. The server smoke starts from an empty Pi-state root and applies this same preflight; the -real protected/tracked sources remain separate read-only mounts. Deterministic fixture tests render +For a clean server bind, `scripts/prepare-server-pi-state.sh` creates the hidden regular Pi agent +mount targets atomically before Compose. The auth target receives the protected credential bind; +the model and settings targets receive generated read-only projections. The server smoke starts +from an empty Pi-state root and applies this same preflight. Deterministic fixture tests render both profiles, verify that bindings stay on `core`, check mount readability, and run the production workspace resolver. Wrong-service, wrong-value, and broken-secret-mount mutations must fail. @@ -189,7 +192,7 @@ an independent 32-minute outer timeout and does not retry a failed command. Current release status (2026-08-05): clean-root render/setup and the production runtime-binding resolver contracts are green. The server fixture supplies all four private trusted claims, including exact non-admin value `0`, and a focused test proves nginx normalization produces the -accepted non-admin backend principal. Canonical schema-v3 registry descriptors now pass through +accepted non-admin backend principal. Canonical schema-v4 registry descriptors now pass through one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration, @@ -295,14 +298,12 @@ Copy `deploy/secrets/thothii.secrets.example` to a protected host file, include keys, and set its absolute path as `THT_SECRETS_FILE` in the operator env. Keep Pi's native provider auth in the separate protected file named by `PI_AUTH_FILE`. -Description Generation is configured independently in the protected installation descriptor under -`metadataGeneration`. Set `THT_INSTALLATION_CONFIG_SOURCE` to that exact host file; Compose mounts -it read-only into `core` and supplies the fixed runtime `THT_INSTALLATION_CONFIG_FILE` path. Each -keyed model stores only an audited `apiKeyEnv` reference. The referenced value stays in the secret -bundle; a model may omit `apiKeyEnv` only when it declares an explicit endpoint that accepts -unauthenticated requests. The browser receives only model IDs, labels, and the configured default. -Configuration changes take effect after restart and do not use Pi settings or workspace -`llm_policy`. +Interactive sessions, Description Generation, and embedding share the protected installation +descriptor's `modelCatalog`. Set `THT_INSTALLATION_CONFIG_SOURCE` to that exact host file; `tht` +validates it and generates the runtime catalog, Pi adapters, and Compose override before startup. +Each authenticated provider stores only an audited `apiKeyEnv` reference; the referenced value stays +in the secret bundle. A provider may use `authentication.mode: none` only with an explicit keyless +endpoint. The browser receives only eligible model IDs, labels, and the catalog default. Before enabling Description Generation, approve the selected model provider for bounded source-data disclosure. Every catalog column has a **Sensitive** flag that defaults to `false`. Administrators can @@ -333,22 +334,11 @@ the host/secret-manager materialization and add a reviewed Compose override that does not create that mount. The frontend remains on loopback; the authenticated host proxy is the only public listener. -Set the selected model provider in application settings (or `PI_PROVIDER`). For each Pi spawn the -backend validates and reads `THT_MODEL_API_KEY` from the bundle, then exposes its value only as the provider's -recognized child variable (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or -`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by -Pi. Local providers such as Ollama require no model key. - -`THT_MODEL_API_KEY` supports Pi providers whose authentication is exactly one key: -`ant-ling`, `anthropic`, `cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google` -(including the `gemini` alias), `google-vertex` when using its API-key mode, `groq`, -`huggingface`, `kimi-coding`, `minimax`, `minimax-cn`, `mistral`, `moonshotai`, -`moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `together`, -`vercel-ai-gateway`, `xai`, the four `xiaomi*` providers, `zai`, and `zai-coding-cn`. -Compound providers are deliberately unsupported: `amazon-bedrock`, `azure-openai-responses`, -`cloudflare-workers-ai`, and `cloudflare-ai-gateway` require multiple credential/configuration -values. Selecting one fails before Pi starts; ambient AWS, Azure, and Cloudflare credentials are -still scrubbed. Supporting them requires a future dedicated provider-specific configuration. +For each Pi spawn, the backend resolves the selected canonical provider/model in the runtime catalog, +reads exactly that provider's declared `apiKeyEnv` value from the bundle, and exposes only that key +to the child. Ambient provider credentials and secret-bundle paths are scrubbed. Providers needing a +compound credential bundle remain unsupported until the catalog gains an explicit generic contract +for them. ## User-owned session server cutover diff --git a/backend/package.json b/backend/package.json index a71ece93..1c3a96f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,8 @@ "catalog:migrate": "node dist/catalog/migrate.js", "test": "vitest run", "start": "node dist/server.js", - "test:schema-v3-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs" + "test:schema-v4-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs", + "test:schema-v3-verifier": "npm run test:schema-v4-verifier" }, "dependencies": { "@fastify/cookie": "11.1.2", diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index 1752504a..73c3159e 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -1195,13 +1195,8 @@ export async function executeChecks({ checks, failAt, recorder } = {}) { function baseWorkspace(id, evidenceSource) { return { - workspace: { schema_version: 3, id, name: `P1 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P1 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } }, }; } diff --git a/backend/scripts/p1-manual-acceptance.mjs b/backend/scripts/p1-manual-acceptance.mjs index 89407388..7b5dfb51 100755 --- a/backend/scripts/p1-manual-acceptance.mjs +++ b/backend/scripts/p1-manual-acceptance.mjs @@ -109,7 +109,7 @@ async function validateDistFiles(repo,files){const dist=join(repo,"backend","dis export async function readManualOwnership({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),root=fixedManualRoot(repo);noSymlinkExisting(repo,root);let rootEntry,ownershipEntry;try{rootEntry=await lstat(root);ownershipEntry=await lstat(join(root,"ownership.json"));}catch{throw new Error("manual ownership is missing");}if(!rootEntry.isDirectory()||rootEntry.isSymbolicLink()||await realpath(root)!==root||!ownershipEntry.isFile()||ownershipEntry.isSymbolicLink())throw new Error("manual ownership is unsafe");let value;try{value=JSON.parse(await readFile(join(root,"ownership.json"),"utf8"));}catch{throw new Error("manual ownership is malformed");}const baseValid=value.schemaVersion===1&&value.kind==="p1-manual-acceptance"&&HEX64.test(value.nonce??"")&&value.repositoryRoot===repo&&value.root===root&&value.status==="PENDING"&&["PREPARING","READY"].includes(value.stage)&&value.listener?.host===HOST&&value.listener?.port===PORT&&value.listener?.state==="stopped"&&typeof value.createdAt==="string"&&validEntrypoint(value.entrypoint,repo)&&validDistManifest(value.distManifest,root)&&JSON.stringify(value.resources)===JSON.stringify([root,{kind:"fastify",host:HOST,port:PORT}]);const readyLog=value.backendLog?.path===join(root,"logs/backend.log")&&Number.isSafeInteger(value.backendLog?.dev)&&Number.isSafeInteger(value.backendLog?.ino);if(!baseValid||(value.stage==="READY"?!readyLog:value.backendLog!==null))throw new Error("manual ownership identity mismatch");return value;} async function run(executable,argv,options={}){return await exec(executable,argv,{...options,maxBuffer:2*1024*1024,encoding:"utf8"});} -function descriptor(id,source){return{workspace:{schema_version:3,id,name:`P1 ${id}`,language:"en"},dwh:{engine:"postgres",database:"postgres",schema:"public",supported_transports:["postgres_direct"]},semantic_index:{vector_store:{engine:"qdrant",collection:id,dimensions:1024,distance:"cosine"},embedding:{provider:"ollama_internal",model:"qwen3-embedding:0.6b",dimensions:1024}},llm_policy:{allowed:["zai/glm-5.2"]},evidence:{source,policy:{max_chunk_chars:4000,retain_published_generations:3}}};} +function descriptor(id,source){return{workspace:{schema_version:4,id,name:`P1 ${id}`,language:"en"},dwh:{engine:"postgres",database:"postgres",schema:"public",supported_transports:["postgres_direct"]},evidence:{source,policy:{max_chunk_chars:4000,retain_published_generations:3}}};} function descriptors(){return[descriptor("p1-filesystem",{type:"filesystem",uri:"workspace-content/p1-filesystem/evidence",patterns:["**/*.md"],max_bytes:10485760}),descriptor("p1-http",{type:"http",uris:["https://evidence.example.test/guide.md"],authentication:"signed_urls_file",connect_timeout_ms:1250,read_timeout_ms:30001,max_bytes:12345,max_redirects:2,allow_private_hosts:false,max_cache_bytes:67890}),descriptor("p1-s3",{type:"s3",uri:"s3://p1-evidence/published/",endpoint_url:"https://s3.example.test/",region:"eu-west-1",credentials:"static_files",trusted_endpoint:true,allow_private_endpoint:false,allow_insecure_endpoint:false,max_bytes:12345,max_objects:33,max_pages:4,page_size:5})];} function quote(value){return `'${String(value).replaceAll("'",`'"'"'`)}'`;} async function checkPrerequisites(repo){for(const path of ["scripts/p1-acceptance.sh","scripts/test-p1-acceptance.sh","backend/scripts/p1-acceptance.mjs","backend/dist/server.js"]){try{await access(join(repo,path));}catch{throw new Error(`Task 8 prerequisite is missing: ${path}`);}}for(const command of ["node","npm","git","curl","unzip","zipinfo","lsof","python3"]){try{await run(command,[command==="unzip"||command==="lsof"?"-v":command==="zipinfo"?"-h":"--version"]);}catch{throw new Error(`missing prerequisite: ${command}`);}}const tht=join(repo,"harness",".venv","bin","tht");try{await access(tht,constants.X_OK);}catch{throw new Error("missing prerequisite: harness/.venv/bin/tht");}} diff --git a/backend/scripts/p1-manual-acceptance.test.mjs b/backend/scripts/p1-manual-acceptance.test.mjs index 2ae83d01..7f9f0172 100644 --- a/backend/scripts/p1-manual-acceptance.test.mjs +++ b/backend/scripts/p1-manual-acceptance.test.mjs @@ -403,7 +403,7 @@ test("generated render command validates saved responses and owned snapshot befo }); const renderSnapshotYaml=`workspace: - schema_version: 3 + schema_version: 4 id: p1-filesystem name: P1 filesystem language: en @@ -412,11 +412,6 @@ dwh: database: postgres schema: public supported_transports: [postgres_direct] -semantic_index: - vector_store: {engine: qdrant, collection: p1-filesystem, dimensions: 1024, distance: cosine} - embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024} -llm_policy: - allowed: [zai/glm-5.2] evidence: source: {type: filesystem, uri: workspace-content/p1-filesystem/evidence, patterns: ["**/*.md"], max_bytes: 10485760} policy: {max_chunk_chars: 4000, retain_published_generations: 3} diff --git a/backend/scripts/p1-render-snapshot.test.mjs b/backend/scripts/p1-render-snapshot.test.mjs index 1660aa57..fb18011b 100644 --- a/backend/scripts/p1-render-snapshot.test.mjs +++ b/backend/scripts/p1-render-snapshot.test.mjs @@ -16,7 +16,7 @@ async function fixture() { await writeFile(join(root,"installation/base.yaml"),"{}\n"); const secret=join(root,"fixture-secrets/dwh-password"); await writeFile(secret,"not-inspected",{mode:0o600}); await writeFile(snapshot,`workspace: - schema_version: 3 + schema_version: 4 id: p1-filesystem name: P1 filesystem language: en @@ -25,11 +25,6 @@ dwh: database: postgres schema: public supported_transports: [postgres_direct] -semantic_index: - vector_store: {engine: qdrant, collection: p1-filesystem, dimensions: 1024, distance: cosine} - embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024} -llm_policy: - allowed: [zai/glm-5.2] evidence: source: {type: filesystem, uri: workspace-content/p1-filesystem/evidence, patterns: ["**/*.md"], max_bytes: 10485760} policy: {max_chunk_chars: 4000, retain_published_generations: 3} @@ -61,7 +56,7 @@ test("renderer refuses snapshot manifest head, digest, and expected-digest tampe test("renderer refuses a missing or malformed snapshot manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/nomanifest.yaml"); await rm(f.manifestPath); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*(missing|unbounded|unsafe)/); await writeFile(f.manifestPath,"{not json"); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*malformed/); await assert.rejects(lstat(output)); assert.deepEqual(await runtimeLeases(f),[]); }); -test("renderer rejects a regular snapshot replacement against its manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await writeFile(f.snapshot,"workspace:\n schema_version: 3\n id: p1-filesystem\n name: replaced\n")}}),/snapshot content changed/); await assert.rejects(lstat(output)); }); +test("renderer rejects a regular snapshot replacement against its manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await writeFile(f.snapshot,"workspace:\n schema_version: 4\n id: p1-filesystem\n name: replaced\n")}}),/snapshot content changed/); await assert.rejects(lstat(output)); }); test("renderer anchors publication when rendered parent is concurrently swapped", async()=>{ const f=await fixture(),output=join(f.root,"rendered/raced.yaml"),moved=join(f.root,"rendered-moved"),outside=join(f.repo,"outside-rendered"); await mkdir(outside); diff --git a/backend/scripts/p11-acceptance.mjs b/backend/scripts/p11-acceptance.mjs index 0a2c4864..63cb75a6 100644 --- a/backend/scripts/p11-acceptance.mjs +++ b/backend/scripts/p11-acceptance.mjs @@ -328,13 +328,8 @@ async function tht(ctx, argv, options = {}) { function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); } function baseWorkspace(id, evidenceSource) { return { - workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } }, }; } diff --git a/backend/scripts/p11-manual-acceptance.mjs b/backend/scripts/p11-manual-acceptance.mjs index 18c9cd0d..35058627 100644 --- a/backend/scripts/p11-manual-acceptance.mjs +++ b/backend/scripts/p11-manual-acceptance.mjs @@ -81,13 +81,8 @@ async function git(executable, argv, options = {}) { function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); } function baseWorkspace(id, evidenceSource) { return { - workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" }, dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } }, }; } diff --git a/backend/scripts/p2-acceptance.mjs b/backend/scripts/p2-acceptance.mjs index d62d6542..37ad8fb5 100644 --- a/backend/scripts/p2-acceptance.mjs +++ b/backend/scripts/p2-acceptance.mjs @@ -375,16 +375,11 @@ function installationProjectName(installationPath) { function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) { return { - workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, diagnostics: { dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, }, - llm_policy: { allowed: ["zai/glm-5.2"] }, ...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}), }; } diff --git a/backend/scripts/p2p6-acceptance.mjs b/backend/scripts/p2p6-acceptance.mjs index 2f3b9f2d..cac63971 100644 --- a/backend/scripts/p2p6-acceptance.mjs +++ b/backend/scripts/p2p6-acceptance.mjs @@ -376,16 +376,11 @@ function installationProjectName(installationPath) { function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) { return { - workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, diagnostics: { dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, }, - llm_policy: { allowed: ["zai/glm-5.2"] }, ...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}), }; } diff --git a/backend/scripts/p3-acceptance.mjs b/backend/scripts/p3-acceptance.mjs index b851af05..b2de8811 100644 --- a/backend/scripts/p3-acceptance.mjs +++ b/backend/scripts/p3-acceptance.mjs @@ -379,16 +379,11 @@ function installationProjectName(installationPath) { function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) { return { - workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, diagnostics: { dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, }, - llm_policy: { allowed: ["zai/glm-5.2"] }, ...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}), }; } diff --git a/backend/scripts/p5-acceptance.mjs b/backend/scripts/p5-acceptance.mjs index 6f17bf52..3b0e3c79 100644 --- a/backend/scripts/p5-acceptance.mjs +++ b/backend/scripts/p5-acceptance.mjs @@ -374,16 +374,11 @@ function installationProjectName(installationPath) { function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) { return { - workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, diagnostics: { dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, }, - llm_policy: { allowed: ["zai/glm-5.2"] }, ...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}), }; } diff --git a/backend/scripts/p6-acceptance.mjs b/backend/scripts/p6-acceptance.mjs index c11bf50a..84a60f82 100644 --- a/backend/scripts/p6-acceptance.mjs +++ b/backend/scripts/p6-acceptance.mjs @@ -374,16 +374,11 @@ function installationProjectName(installationPath) { function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) { return { - workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" }, + workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, diagnostics: { dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, }, - llm_policy: { allowed: ["zai/glm-5.2"] }, ...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}), }; } diff --git a/backend/scripts/verify-workspace-descriptor-files.mjs b/backend/scripts/verify-workspace-descriptor-files.mjs index 5319b657..410cb1de 100755 --- a/backend/scripts/verify-workspace-descriptor-files.mjs +++ b/backend/scripts/verify-workspace-descriptor-files.mjs @@ -36,11 +36,10 @@ const reviewedExpandableBlocks = new Map([ { sha256: "b903e5dae953ae1372f1a5276f12a92ed3dd632b897f3afe5e00c646d90a1b42", rationale: "Same reviewed block in the repository-required CRLF checkout representation." }, ]], ["scripts/unified-deployment-smoke.sh", [ - { sha256: "1d60bf140165a8fabfa0c3729e776136904717e67becf3e0ab68c70d8e37847e", rationale: "Generates reviewed Task 13 runtime configuration." }, - { sha256: "36d3d8a2362dbdc4fad90948d6c227586d749f56b9a4bc5b6b5a91bcbec6407b", rationale: "Generates the reviewed local Task 13 Compose override." }, - { sha256: "c556f7d910d0788e219b042957e6b307cb9925b43920c680535d0d3a6dcbdb25", rationale: "Generates the reviewed local Task 13 installation descriptor." }, + { sha256: "b6c0826151b2c8b955399d1abf5b691cc8fe6b6454b17da000dde7ba3bc55d2d", rationale: "Generates the reviewed local Task 13 Compose override with normalized catalog mounts." }, + { sha256: "24f69d12b8554aa2bebba455be99fde3e60743eef5a40fa2ef5b29397a477c03", rationale: "Generates the reviewed local Task 13 installation descriptor with its model catalog." }, { sha256: "526006fa6d48a8080b3834723630c64de5005a67243e944ebf1da15212b4d654", rationale: "Generates the reviewed server Task 13 Compose override." }, - { sha256: "c57ae2205c21ead0c2015a353aaabb948fa4ddd9b78a2cdcdb71f48cf2db742d", rationale: "Generates the reviewed projected-auth server Task 13 installation descriptor." }, + { sha256: "406ccead1967f642225c946fc4a23fe5b019c9764cc5153e1125876ade16ec90", rationale: "Generates the reviewed projected-auth server Task 13 installation descriptor with its model catalog." }, ]], ["scripts/vector-backup.sh", [ { sha256: "571899db49dfdcec8107fbe1e0a86a61e7581979d3c4c248c20546843e275bcf", rationale: "Generates the reviewed backup manifest inside the helper command." }, @@ -103,6 +102,7 @@ function isPolicyImplementationException(label, category) { ]); if (implementations.has(label)) return true; if (category === "migration-marker" && new Set([ + "backend/src/workspaces/schema.ts", "scripts/workspace_descriptor_doc_contract.py", "scripts/test_workspace_descriptor_doc_contract.py", "backend/scripts/clean-dist.test.mjs", @@ -173,7 +173,7 @@ function validateWorkspaceSource(source, label, { requireWorkspace, expandable = try { parseWorkspaceYaml(source); } catch (error) { - throw new Error(`${label}: workspace descriptor is not valid schema v3: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`${label}: workspace descriptor is not valid schema v4: ${error instanceof Error ? error.message : String(error)}`); } return true; } diff --git a/backend/scripts/verify-workspace-descriptor-files.test.mjs b/backend/scripts/verify-workspace-descriptor-files.test.mjs index a66bc921..02b54315 100644 --- a/backend/scripts/verify-workspace-descriptor-files.test.mjs +++ b/backend/scripts/verify-workspace-descriptor-files.test.mjs @@ -33,20 +33,20 @@ function bashN(root, path) { function replaceWorkspaceKeys(source, workspaceKey, schemaLine) { return source .replace(/^workspace:$/m, workspaceKey) - .replace(/^ schema_version: 3$/m, schemaLine); + .replace(/^ schema_version: 4$/m, schemaLine); } -test("production parser accepts semantic v3 with quoted Unicode/tagged keys and spacing", async (t) => { +test("production parser accepts semantic v4 with quoted Unicode/tagged keys and spacing", async (t) => { const root = await fixture(t); const unicode = replaceWorkspaceKeys( canonicalDescriptor, '"\\u0077orkspace" :', - ' "\\u0073chema_version" : 3', + ' "\\u0073chema_version" : 4', ); const tagged = replaceWorkspaceKeys( canonicalDescriptor, "!!str workspace :", - " !!str schema_version : 3", + " !!str schema_version : 4", ); await put(root, "deploy/workspaces/unicode.yaml", unicode); await put(root, "deploy/workspaces/tagged.yaml", tagged); @@ -59,14 +59,15 @@ test("production parser accepts semantic v3 with quoted Unicode/tagged keys and }); }); -test("production parser rejects fancy keys with every non-v3 or ambiguous value", async (t) => { +test("production parser rejects fancy keys with every non-v4 or ambiguous value", async (t) => { const invalid = [ ["unicode-v2", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 2'], - ["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 02"], - ["hexadecimal", "workspace :", " schema_version : 0x2"], - ["multiline", "workspace :", " schema_version : >\n 3"], - ["duplicate", "workspace :", " schema_version : 3\n schema_version: 3"], - ["inline", "workspace: { schema_version: 3 }", " schema_version: 3"], + ["unicode-v3", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 3'], + ["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 03"], + ["hexadecimal", "workspace :", " schema_version : 0x3"], + ["multiline", "workspace :", " schema_version : >\n 4"], + ["duplicate", "workspace :", " schema_version : 4\n schema_version: 4"], + ["inline", "workspace: { schema_version: 4 }", " schema_version: 4"], ]; for (const [name, workspaceKey, schemaLine] of invalid) { await t.test(name, async () => { @@ -120,7 +121,7 @@ test("PowerShell embedded workspace mappings are rejected while bundle-only stri const root = await fixture(t); const source = [ "$workspace = @'", - canonicalDescriptor.replace(" schema_version: 3", " schema_version: 0x2").trimEnd(), + canonicalDescriptor.replace(" schema_version: 4", " schema_version: 0x2").trimEnd(), "'@", '$bundle = @"', "bundle:", @@ -137,7 +138,7 @@ test("PowerShell embedded workspace mappings are rejected while bundle-only stri test("workspace descriptor family entries require a top-level workspace", async (t) => { const root = await fixture(t); - await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 3\n"); + await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 4\n"); await assert.rejects( verifyEntries({ root, @@ -179,7 +180,7 @@ test("script scalar workspace remains a bundle even with descriptor-like sibling test("standalone descriptor files require workspace to be a mapping", async (t) => { const root = await fixture(t); const path = "scripts/fixtures/workspace-registry-scalar.yaml"; - await put(root, path, "workspace: analytics\nschema_version: 3\n"); + await put(root, path, "workspace: analytics\nschema_version: 4\n"); await assert.rejects( verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }), /workspace.*mapping/i, @@ -193,11 +194,11 @@ test("Bash extractor supports hyphen, digit, escaped delimiters, and tab strippi name: "hyphen-v2", opener: "cat <<'WORKSPACE-YAML'", delimiter: "WORKSPACE-YAML", - descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"), + descriptor: canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2"), rejected: true, }, { - name: "digit-v3", + name: "digit-v4", opener: "cat <<2YAML", delimiter: "2YAML", descriptor: canonicalDescriptor, @@ -207,11 +208,11 @@ test("Bash extractor supports hyphen, digit, escaped delimiters, and tab strippi name: "escaped-v2", opener: "cat < `\t${line}`).join("\n"), @@ -272,7 +273,7 @@ test("non-stripping heredoc close requires an exact physical delimiter line", as "#!/usr/bin/env bash", "cat <<'---'", "--- ", - canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(), + canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(), "---", "", ].join("\n"); @@ -313,7 +314,7 @@ test("double-quoted non-special backslash is preserved in the delimiter", async "#!/usr/bin/env bash", 'cat <<"\\---"', "---", - canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(), + canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(), "\\---", "", ].join("\n"); @@ -355,7 +356,7 @@ test("split heredoc operator continuation cannot bypass v2 validation", async (t "#!/usr/bin/env bash", "cat <\\", "<'YAML'", - canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(), + canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(), "YAML", "", ].join("\n"); @@ -424,7 +425,7 @@ test("PowerShell comment backslash cannot hide a following v2 here-string", asyn const source = [ "# harmless PowerShell comment \\", "$workspace = @'", - canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(), + canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(), "'@", "", ].join("\n"); @@ -435,7 +436,7 @@ test("PowerShell comment backslash cannot hide a following v2 here-string", asyn ); }); -test("PowerShell dialect accepts normal v3 and non-workspace bundle here-strings", async (t) => { +test("PowerShell dialect accepts normal v4 and non-workspace bundle here-strings", async (t) => { const root = await fixture(t); const path = "scripts/powershell-valid-smoke.ps1"; const source = [ @@ -494,10 +495,10 @@ test("PowerShell cast and concatenation openers cannot hide embedded descriptors test("expandable YAML interpolation that can hide a workspace descriptor fails closed", async (t) => { const root = await fixture(t); const cases = [ - ["braced-key", "${key}:\n schema_version: 3"], - ["plain-key", "$key:\n schema_version: 3"], - ["quoted-key", '"$key" :\n schema_version: 3'], - ["subexpression-key", "$($key):\n schema_version: 3"], + ["braced-key", "${key}:\n schema_version: 4"], + ["plain-key", "$key:\n schema_version: 4"], + ["quoted-key", '"$key" :\n schema_version: 4'], + ["subexpression-key", "$($key):\n schema_version: 4"], ["version", "workspace:\n schema_version: $version"], ]; for (const [name, body] of cases) { @@ -564,7 +565,7 @@ test("unmarked expandable Bash YAML cannot generate descriptor keys or values at "key=workspace", "cat < { for (const [path, source] of [ ["scripts/fake-marker.sh", [ "#!/usr/bin/env bash", - "# schema-v3-only: expandable-nonworkspace", + "# schema-v4-only: expandable-nonworkspace", "cat < boolean; @@ -155,17 +158,22 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc semanticRuntime: { internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl, + internalEmbeddingId: config.internalEmbeddingId, internalEmbeddingModel: config.internalEmbeddingModel, internalEmbeddingDimensions: config.internalEmbeddingDimensions, }, }); - const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = deps?.hub ?? new SseHub(); const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry); const catalogRepository = deps?.catalogRepository ?? createCatalogRepository(config.catalogDatabase); const catalogOperationCoordinator = deps?.catalogOperationCoordinator ?? new CatalogOperationCoordinator(); + const runtimeModelCatalog = deps?.runtimeModelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile); + const mgr = deps?.mgr ?? new PiProcessManager(config, { + ...(deps?.spawnFn ? { spawnFn: deps.spawnFn } : {}), + modelCatalog: runtimeModelCatalog, + }); const metadataGenerationModels = deps?.metadataGenerationModels ?? loadMetadataGenerationModels({ - installationFile: config.installationConfigFile, + catalogFile: config.modelCatalogFile, secretsFile: config.secretsFile, }); const modelCompleter = deps?.modelCompleter ?? new PythonModelCompleter({ @@ -237,6 +245,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc ?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, { internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl, + internalEmbeddingId: config.internalEmbeddingId, internalEmbeddingModel: config.internalEmbeddingModel, internalEmbeddingDimensions: config.internalEmbeddingDimensions, }); @@ -271,7 +280,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc const getSettings = async (principal: PrincipalContext): Promise => { if (deps?.getSettings) return await deps.getSettings(principal); const stored = loadSettings(config); - const effective = effectiveSettings(config, stored); + const effective = effectiveSettings(config, stored, runtimeModelCatalog); // In the registry system the legacy `harness/workspaces/*.yaml` default is obsolete: when no // installation workspace is pinned, default to the first active registry workspace. if (!stored.workspace) { @@ -284,7 +293,9 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc } return effective; }; - const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels }); + const piManagement = deps?.piManagement ?? createPiManagement(config, { + modelCatalog: runtimeModelCatalog, + }); const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile); const localRegistryResolver = deps?.localUserRegistry === undefined @@ -408,6 +419,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc dwhPrecheck: config.dwhPrecheck, legacyWorkspaceMode: config.legacyWorkspaceMode, workspaceRuntimeSupport, + modelCatalog: runtimeModelCatalog, maintenanceBarrier, effectiveRelationships, }); @@ -439,7 +451,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc return maintenanceBarrier.status(); }); sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry }); - metaRoutes(app, { harnessDir: config.harnessDir, listModels }); + metaRoutes(app, { harnessDir: config.harnessDir, modelCatalog: runtimeModelCatalog }); workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, @@ -472,7 +484,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc worker: descriptionGenerationWorker, sensitiveDataSuggestionRunner, }); - settingsRoutes(app, { cfg: config, listModels, getSettings }); + settingsRoutes(app, { cfg: config, getSettings }); piManagementRoutes(app, { service: piManagement }); return app; diff --git a/backend/src/catalog/metadata-generation-models.ts b/backend/src/catalog/metadata-generation-models.ts index 6e621b72..f9ea08be 100644 --- a/backend/src/catalog/metadata-generation-models.ts +++ b/backend/src/catalog/metadata-generation-models.ts @@ -1,63 +1,5 @@ -import { - closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, - type Stats, -} from "node:fs"; -import { parseAllDocuments } from "yaml"; -import { z } from "zod"; -import { - loadSecretBundle, - METADATA_GENERATION_SECRET_KEYS, -} from "../config/secret-bundle.js"; - -const MAX_INSTALLATION_BYTES = 1024 * 1024; -const RUNTIME_INSTALLATION_FILE = "/run/thothii-installation/thothii-installation.yaml"; -const modelId = z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/); -const apiKeyEnvironment = z.enum(METADATA_GENERATION_SECRET_KEYS); -const endpointSchema = z.object({ - baseUrl: z.string().min(1).max(2048).refine((value) => { - try { - const url = new URL(value); - return (url.protocol === "http:" || url.protocol === "https:") - && url.username === "" && url.password === "" && url.search === "" && url.hash === ""; - } catch { - return false; - } - }), - apiVersion: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(), -}).strict(); -const configuredModelSchema = z.object({ - id: modelId, - label: z.string().min(1).max(128).refine((value) => value.trim() === value && !/\p{Cc}/u.test(value)), - litellm: z.object({ - provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/), - model: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/), - disableThinking: z.literal(true).optional(), - endpoint: endpointSchema.optional(), - }).strict(), - apiKeyEnv: apiKeyEnvironment.optional(), -}).strict().superRefine((value, context) => { - if (value.apiKeyEnv === undefined && value.litellm.endpoint === undefined) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["apiKeyEnv"], - message: "keyless models require an explicit endpoint", - }); - } - if (value.litellm.disableThinking === true && value.litellm.endpoint === undefined) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["litellm", "disableThinking"], - message: "thinking may be disabled only for an explicit endpoint", - }); - } -}); -const metadataGenerationSchema = z.object({ - default: modelId.optional(), - models: z.array(configuredModelSchema).max(64).default([]), -}).strict(); -const installationSchema = z.object({ - metadataGeneration: metadataGenerationSchema.optional(), -}).passthrough(); +import { loadSecretBundle } from "../config/secret-bundle.js"; +import { loadRuntimeModelCatalog } from "../models/runtime-model-catalog.js"; export interface MetadataGenerationModelChoice { id: string; @@ -86,7 +28,6 @@ export class MetadataGenerationModelUnavailableError extends Error { } } -/** The complete interface callers need: safe discovery plus fail-closed runtime resolution. */ export interface MetadataGenerationModels { catalog(): MetadataGenerationModelCatalog; resolve(selection: string): ResolvedMetadataGenerationModel; @@ -96,140 +37,78 @@ class RestartLoadedMetadataGenerationModels implements MetadataGenerationModels readonly #models: ReadonlyMap; readonly #catalog: MetadataGenerationModelCatalog; - constructor( - models: ReadonlyMap = new Map(), - defaultModel: string | null = null, - choices: MetadataGenerationModelChoice[] = [], - ) { + constructor(models: ReadonlyMap, defaultModel: string | null) { this.#models = models; this.#catalog = { - models: choices.map((choice) => ({ ...choice })), + models: [...models.values()].map(({ id }) => ({ id, label: id })), default: defaultModel, }; } catalog(): MetadataGenerationModelCatalog { - return { - models: this.#catalog.models.map((choice) => ({ ...choice })), - default: this.#catalog.default, - }; + return { models: this.#catalog.models.map((choice) => ({ ...choice })), default: this.#catalog.default }; } resolve(selection: string): ResolvedMetadataGenerationModel { - const model = typeof selection === "string" ? this.#models.get(selection) : undefined; + const model = this.#models.get(selection); if (!model) throw new MetadataGenerationModelUnavailableError(); return model; } } -function invalid(message = "metadata-generation configuration is invalid"): Error { +function invalid(message = "metadata-generation runtime catalog is invalid"): Error { return new Error(message); } -function protectedInstallationStat(file: string, info: Stats): boolean { - const mode = info.mode & 0o777; - if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 - || info.size < 1 || info.size > MAX_INSTALLATION_BYTES) return false; - if (file === RUNTIME_INSTALLATION_FILE && info.uid === 0 && mode === 0o444) return true; - return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600); -} - -function readProtectedInstallation(file: string): string { - let descriptor: number | undefined; - try { - const before = lstatSync(file); - if (!protectedInstallationStat(file, before)) throw new Error("unavailable"); - descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); - const opened = fstatSync(descriptor); - if (!protectedInstallationStat(file, opened) - || before.dev !== opened.dev || before.ino !== opened.ino) throw new Error("unavailable"); - const source = readFileSync(descriptor, "utf8"); - const after = fstatSync(descriptor); - const current = lstatSync(file); - if (!protectedInstallationStat(file, after) || !protectedInstallationStat(file, current) - || opened.dev !== after.dev || opened.ino !== after.ino - || opened.dev !== current.dev || opened.ino !== current.ino) throw new Error("unavailable"); - return source; - } finally { - if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* sanitized below */ } - } -} - -function readInstallation(file: string): unknown { - try { - const documents = parseAllDocuments(readProtectedInstallation(file), { uniqueKeys: true }); - if (documents.length !== 1) throw invalid("metadata-generation installation must contain one YAML document"); - const document = documents[0]; - if (document.errors.length > 0 || document.warnings.length > 0) { - throw invalid("metadata-generation installation contains invalid YAML"); - } - return document.toJSON(); - } catch (error) { - if (error instanceof Error && error.message.startsWith("metadata-generation")) throw error; - throw invalid("metadata-generation installation is unavailable"); - } -} - export function loadMetadataGenerationModels(options: { - installationFile?: string; + catalogFile?: string; secretsFile?: string; }): MetadataGenerationModels { - if (!options.installationFile) return new RestartLoadedMetadataGenerationModels(); - const installation = installationSchema.safeParse(readInstallation(options.installationFile)); - if (!installation.success) throw invalid(); - const configured = installation.data.metadataGeneration; - if (!configured || configured.models.length === 0) { - if (configured?.default !== undefined) throw invalid("metadata-generation default does not identify a configured model"); - return new RestartLoadedMetadataGenerationModels(); - } - if (!configured.default) throw invalid("metadata-generation default is required when models are configured"); + const catalog = loadRuntimeModelCatalog(options.catalogFile); + const configured = catalog.metadataModels(); + if (configured.length === 0) return new RestartLoadedMetadataGenerationModels(new Map(), null); - const seen = new Set(); - for (const model of configured.models) { - if (seen.has(model.id)) throw invalid(`metadata-generation model id "${model.id}" is duplicated`); - seen.add(model.id); - } - if (!seen.has(configured.default)) { - throw invalid(`metadata-generation default "${configured.default}" is not configured`); - } - const requiresSecrets = configured.models.some((model) => model.apiKeyEnv !== undefined); + const requiresSecrets = configured.some((model) => model.authentication.mode === "secret_env"); let secrets: ReadonlyMap = new Map(); if (requiresSecrets) { if (!options.secretsFile) throw invalid("metadata-generation keyed models require THT_SECRETS_FILE"); - try { - secrets = loadSecretBundle(options.secretsFile); - } catch { - throw invalid("metadata-generation secrets are unavailable"); - } + try { secrets = loadSecretBundle(options.secretsFile); } + catch { throw invalid("metadata-generation secrets are unavailable"); } } + const models = new Map(); - for (const configuredModel of configured.models) { + const labels = new Map(); + for (const configuredModel of configured) { + const adapter = configuredModel.metadataAdapter; + if (!adapter || configuredModel.authentication.mode === "pi_auth") throw invalid(); + const apiKeyEnv = configuredModel.authentication.apiKeyEnv; let apiKey: string | undefined; - if (configuredModel.apiKeyEnv !== undefined) { - apiKey = secrets.get(configuredModel.apiKeyEnv); - if (!apiKey) { - throw invalid(`metadata-generation model "${configuredModel.id}" secret "${configuredModel.apiKeyEnv}" is missing`); - } + if (configuredModel.authentication.mode === "secret_env") { + if (!apiKeyEnv) throw invalid(); + apiKey = secrets.get(apiKeyEnv); + if (!apiKey) throw invalid(`metadata-generation model "${configuredModel.id}" secret "${apiKeyEnv}" is missing`); if (apiKey.length > 16 * 1024 || /\s/u.test(apiKey)) { - throw invalid(`metadata-generation model "${configuredModel.id}" secret "${configuredModel.apiKeyEnv}" is unusable`); + throw invalid(`metadata-generation model "${configuredModel.id}" secret "${apiKeyEnv}" is unusable`); } } + labels.set(configuredModel.id, configuredModel.label); models.set(configuredModel.id, Object.freeze({ id: configuredModel.id, - provider: configuredModel.litellm.provider, - model: configuredModel.litellm.model, - ...(configuredModel.litellm.disableThinking === true ? { disableThinking: true as const } : {}), - ...(configuredModel.litellm.endpoint === undefined - ? {} - : { endpoint: Object.freeze({ ...configuredModel.litellm.endpoint }) }), - ...(configuredModel.apiKeyEnv === undefined - ? {} - : { apiKeyEnv: configuredModel.apiKeyEnv, apiKey }), + provider: adapter.litellmProvider, + model: configuredModel.upstreamModel, + ...(configuredModel.metadataGeneration?.disableThinking === true + ? { disableThinking: true as const } : {}), + ...(configuredModel.endpoint ? { endpoint: Object.freeze({ ...configuredModel.endpoint }) } : {}), + ...(apiKeyEnv ? { apiKeyEnv, apiKey } : {}), })); } - return new RestartLoadedMetadataGenerationModels( - models, - configured.default, - configured.models.map(({ id, label }) => ({ id, label })), - ); + const result = new RestartLoadedMetadataGenerationModels(models, catalog.defaultMetadataGeneration); + const safe = result.catalog(); + return { + catalog: () => ({ + default: safe.default, + models: safe.models.map((choice) => ({ ...choice, label: labels.get(choice.id) ?? choice.id })), + }), + resolve: (selection) => result.resolve(selection), + }; } diff --git a/backend/src/catalog/migrate.ts b/backend/src/catalog/migrate.ts index d04623a6..a2121795 100644 --- a/backend/src/catalog/migrate.ts +++ b/backend/src/catalog/migrate.ts @@ -12,6 +12,7 @@ import * as sensitiveDataFlagMigration from "./migrations/006_sensitive_data_fla import * as sensitiveDataSuggestionRunsMigration from "./migrations/007_sensitive_data_suggestion_runs.js"; import * as catalogLogicalRelationshipsMigration from "./migrations/008_catalog_logical_relationships.js"; import * as aiTokenUsageMigration from "./migrations/009_ai_token_usage.js"; +import * as canonicalModelIdsMigration from "./migrations/010_canonical_model_ids.js"; const connectionString = process.env.THT_CATALOG_MIGRATOR_DATABASE_URL; const host = process.env.THT_CATALOG_DB_HOST; @@ -46,6 +47,7 @@ const provider: MigrationProvider = { "007_sensitive_data_suggestion_runs": sensitiveDataSuggestionRunsMigration, "008_catalog_logical_relationships": catalogLogicalRelationshipsMigration, "009_ai_token_usage": aiTokenUsageMigration, + "010_canonical_model_ids": canonicalModelIdsMigration, }; }, }; diff --git a/backend/src/catalog/migrations/010_canonical_model_ids.ts b/backend/src/catalog/migrations/010_canonical_model_ids.ts new file mode 100644 index 00000000..2cdfa8cd --- /dev/null +++ b/backend/src/catalog/migrations/010_canonical_model_ids.ts @@ -0,0 +1,27 @@ +import { sql, type Kysely } from "kysely"; +import type { CatalogDatabase } from "../repository.js"; + +const canonicalModelPattern = "^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"; +const legacyModelPattern = "^[a-z][a-z0-9._-]{0,63}$"; + +export async function up(db: Kysely): Promise { + await sql.raw(`alter table description_generation_runs + drop constraint description_generation_runs_model_id_check, + add constraint description_generation_runs_model_id_check + check (model_id ~ '${canonicalModelPattern}')`).execute(db); + await sql.raw(`alter table sensitive_data_suggestion_runs + drop constraint sensitive_data_suggestion_runs_model_id_check, + add constraint sensitive_data_suggestion_runs_model_id_check + check (model_id ~ '${canonicalModelPattern}')`).execute(db); +} + +export async function down(db: Kysely): Promise { + await sql.raw(`alter table sensitive_data_suggestion_runs + drop constraint sensitive_data_suggestion_runs_model_id_check, + add constraint sensitive_data_suggestion_runs_model_id_check + check (model_id ~ '${legacyModelPattern}')`).execute(db); + await sql.raw(`alter table description_generation_runs + drop constraint description_generation_runs_model_id_check, + add constraint description_generation_runs_model_id_check + check (model_id ~ '${legacyModelPattern}')`).execute(db); +} diff --git a/backend/src/config.ts b/backend/src/config.ts index 5b2e3787..59bb44b8 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -32,6 +32,7 @@ export interface AppConfig { piManagementTimeoutMs: number; secretsFile?: string; installationConfigFile?: string; + modelCatalogFile?: string; piAuthFile?: string; secretFiles: Readonly>; modelApiKeyFile?: string; @@ -50,6 +51,7 @@ export interface AppConfig { workspaceSecretRuntimeRoot: string; internalQdrantUrl: string; internalEmbeddingUrl: string; + internalEmbeddingId: string; internalEmbeddingModel: string; internalEmbeddingDimensions: number; } @@ -189,6 +191,21 @@ function positiveDimension(value: string | undefined, fallback: number): number return parsed; } +function internalEmbeddingIdentity( + identityValue: string | undefined, + modelValue: string | undefined, +): { id: string; model: string } { + const id = identityValue ?? "ollama/qwen3-embedding:0.6b"; + if (!/^ollama\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(id)) { + throw new Error("internal embedding identity configuration is invalid"); + } + const model = id.slice(id.indexOf("/") + 1); + if (modelValue !== undefined && modelValue !== model) { + throw new Error("internal embedding model does not match its canonical identity"); + } + return { id, model }; +} + function catalogDatabase(env: Record): CatalogConnectionConfig | undefined { const value = env.THT_CATALOG_DATABASE_URL; if (value !== undefined) { @@ -330,6 +347,13 @@ export function loadConfig( || installationConfigFile.includes("\0") || !path.isAbsolute(installationConfigFile) )) throw new Error("installation configuration is invalid"); + const modelCatalogFile = env.THT_MODEL_CATALOG_FILE; + if (modelCatalogFile !== undefined && ( + modelCatalogFile.trim() !== modelCatalogFile + || modelCatalogFile.length === 0 + || modelCatalogFile.includes("\0") + || !path.isAbsolute(modelCatalogFile) + )) throw new Error("runtime model catalog configuration is invalid"); const piAuthFile = env.THT_PI_AUTH_FILE; if (piAuthFile !== undefined && ( piAuthFile.trim() !== piAuthFile || piAuthFile.length === 0 || piAuthFile.includes("\0") @@ -391,6 +415,10 @@ export function loadConfig( "internal embedding URL", ["embedding", "localhost"], ); + const internalEmbedding = internalEmbeddingIdentity( + env.THT_INTERNAL_EMBEDDING_ID, + env.THT_INTERNAL_EMBEDDING_MODEL, + ); return { host: env.HOST ?? "127.0.0.1", port: Number(env.PORT ?? 8787), @@ -404,7 +432,7 @@ export function loadConfig( publicExposure, sessionStorage, catalogDatabase: catalogDatabase(env), - defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, + defaults: { thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), settingsFile, maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"), @@ -413,6 +441,7 @@ export function loadConfig( piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS), secretsFile, installationConfigFile, + modelCatalogFile, piAuthFile, secretFiles, modelApiKeyFile, @@ -425,7 +454,8 @@ export function loadConfig( workspaceSecretRuntimeRoot, internalQdrantUrl, internalEmbeddingUrl, - internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b", + internalEmbeddingId: internalEmbedding.id, + internalEmbeddingModel: internalEmbedding.model, internalEmbeddingDimensions: positiveDimension(env.THT_INTERNAL_EMBEDDING_DIMENSIONS, 1024), }; } diff --git a/backend/src/config/secret-bundle.ts b/backend/src/config/secret-bundle.ts index e2b5b1ac..dfde0ebd 100644 --- a/backend/src/config/secret-bundle.ts +++ b/backend/src/config/secret-bundle.ts @@ -8,7 +8,7 @@ import { isUsableAuthenticationSecret, } from "../auth/secret-policy.js"; -/** Credential names that metadata-generation model entries may reference. */ +/** Credential names that Installation Model Catalog providers may reference. */ export const METADATA_GENERATION_SECRET_KEYS = Object.freeze([ "THT_METADATA_API_KEY", "ANTHROPIC_API_KEY", "AZURE_API_KEY", "GEMINI_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY", diff --git a/backend/src/models/runtime-model-catalog.ts b/backend/src/models/runtime-model-catalog.ts new file mode 100644 index 00000000..3c1dfde6 --- /dev/null +++ b/backend/src/models/runtime-model-catalog.ts @@ -0,0 +1,161 @@ +import { + closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type Stats, +} from "node:fs"; +import { z } from "zod"; + +const MAX_CATALOG_BYTES = 1024 * 1024; +const RUNTIME_CATALOG_FILE = "/run/thothii-model-catalog/catalog.json"; +const canonicalId = z.string().regex(/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/); +const secretBundleKey = /^[A-Z][A-Z0-9_]{0,63}$/; + +const endpointSchema = z.object({ + baseUrl: z.string().url(), + apiVersion: z.string().optional(), +}).strict(); + +const authenticationSchema = z.object({ + mode: z.enum(["secret_env", "pi_auth", "none"]), + apiKeyEnv: z.string().optional(), +}).strict(); + +const runtimeModelSchema = z.object({ + id: canonicalId, + provider: z.string().min(1), + model: z.string().min(1), + label: z.string().min(1), + upstreamModel: z.string().min(1), + endpoint: endpointSchema.optional(), + authentication: authenticationSchema, + sessionAdapter: z.object({ mode: z.enum(["pi_builtin", "openai_compatible"]) }).strict().optional(), + metadataAdapter: z.object({ litellmProvider: z.string().min(1) }).strict().optional(), + session: z.object({ + reasoning: z.boolean(), + input: z.array(z.string()).optional(), + cost: z.object({ + input: z.number(), output: z.number(), cacheRead: z.number(), cacheWrite: z.number(), + }).strict().optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), + compatibility: z.object({ + supportsDeveloperRole: z.boolean(), + supportsReasoningEffort: z.boolean(), + supportsStore: z.boolean(), + maxTokensField: z.string().optional(), + }).strict().optional(), + }).strict().optional(), + metadataGeneration: z.object({ disableThinking: z.boolean() }).strict().optional(), +}).strict(); + +const catalogSchema = z.object({ + schemaVersion: z.literal(1), + defaultSession: canonicalId, + defaultMetadataGeneration: canonicalId.optional(), + embedding: z.object({ id: canonicalId, dimensions: z.number().int().positive() }).strict(), + models: z.array(runtimeModelSchema).max(64), +}).strict(); + +export type RuntimeModel = z.infer; + +export interface RuntimeModelCatalog { + readonly defaultSession: string | null; + readonly defaultMetadataGeneration: string | null; + readonly embedding: Readonly<{ id: string; dimensions: number }> | null; + sessionModels(): readonly RuntimeModel[]; + metadataModels(): readonly RuntimeModel[]; + hasSession(id: string): boolean; +} + +class RestartLoadedRuntimeModelCatalog implements RuntimeModelCatalog { + readonly defaultSession: string | null; + readonly defaultMetadataGeneration: string | null; + readonly embedding: Readonly<{ id: string; dimensions: number }> | null; + readonly #sessions: readonly RuntimeModel[]; + readonly #metadata: readonly RuntimeModel[]; + readonly #sessionIds: ReadonlySet; + + constructor(catalog?: z.infer) { + this.defaultSession = catalog?.defaultSession ?? null; + this.defaultMetadataGeneration = catalog?.defaultMetadataGeneration ?? null; + this.embedding = catalog ? Object.freeze({ ...catalog.embedding }) : null; + this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) => model.session !== undefined)); + this.#metadata = Object.freeze((catalog?.models ?? []).filter((model) => model.metadataGeneration !== undefined)); + this.#sessionIds = new Set(this.#sessions.map((model) => model.id)); + } + + sessionModels(): readonly RuntimeModel[] { return this.#sessions.map((model) => ({ ...model })); } + metadataModels(): readonly RuntimeModel[] { return this.#metadata.map((model) => ({ ...model })); } + hasSession(id: string): boolean { return this.#sessionIds.has(id); } +} + +function protectedCatalogStat(file: string, info: Stats): boolean { + const mode = info.mode & 0o777; + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 + || info.size < 1 || info.size > MAX_CATALOG_BYTES) return false; + if (file === RUNTIME_CATALOG_FILE && info.uid === 0 && (mode === 0o444 || mode === 0o644)) return true; + return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600 || mode === 0o644); +} + +function readProtectedCatalog(file: string): unknown { + let descriptor: number | undefined; + try { + const before = lstatSync(file); + if (!protectedCatalogStat(file, before)) throw new Error("runtime model catalog is unavailable"); + descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + const opened = fstatSync(descriptor); + if (!protectedCatalogStat(file, opened) + || before.dev !== opened.dev || before.ino !== opened.ino) throw new Error("runtime model catalog is unavailable"); + const source = readFileSync(descriptor, "utf8"); + const after = fstatSync(descriptor); + const current = lstatSync(file); + if (!protectedCatalogStat(file, after) || !protectedCatalogStat(file, current) + || opened.dev !== after.dev || opened.ino !== after.ino + || opened.dev !== current.dev || opened.ino !== current.ino) throw new Error("runtime model catalog is unavailable"); + return JSON.parse(source); + } catch { + throw new Error("runtime model catalog is unavailable"); + } finally { + if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* sanitized above */ } + } +} + +export function loadRuntimeModelCatalog(file?: string): RuntimeModelCatalog { + if (!file) return new RestartLoadedRuntimeModelCatalog(); + const parsed = catalogSchema.safeParse(readProtectedCatalog(file)); + if (!parsed.success) throw new Error("runtime model catalog is invalid"); + if (parsed.data.models.some((model) => !validRuntimeModel(model))) { + throw new Error("runtime model catalog is invalid"); + } + const ids = new Set(parsed.data.models.map((model) => model.id)); + if (ids.size !== parsed.data.models.length) throw new Error("runtime model catalog contains duplicate models"); + const sessions = parsed.data.models.filter((model) => model.session !== undefined).map((model) => model.id); + const metadata = parsed.data.models.filter((model) => model.metadataGeneration !== undefined).map((model) => model.id); + if (!sessions.includes(parsed.data.defaultSession)) throw new Error("runtime model catalog session default is invalid"); + if ((metadata.length > 0) !== (parsed.data.defaultMetadataGeneration !== undefined) + || (parsed.data.defaultMetadataGeneration !== undefined + && !metadata.includes(parsed.data.defaultMetadataGeneration))) { + throw new Error("runtime model catalog metadata default is invalid"); + } + return new RestartLoadedRuntimeModelCatalog(parsed.data); +} + +function validRuntimeModel(model: RuntimeModel): boolean { + if ((model.session !== undefined) !== (model.sessionAdapter !== undefined)) return false; + if ((model.metadataGeneration !== undefined) !== (model.metadataAdapter !== undefined)) return false; + switch (model.authentication.mode) { + case "secret_env": + return model.authentication.apiKeyEnv !== undefined + && secretBundleKey.test(model.authentication.apiKeyEnv); + case "pi_auth": + return model.authentication.apiKeyEnv === undefined + && model.metadataGeneration === undefined + && model.sessionAdapter?.mode === "pi_builtin"; + case "none": + return model.authentication.apiKeyEnv === undefined && model.endpoint !== undefined; + } +} + +export function splitCanonicalModelId(id: string): { provider: string; model: string } { + const slash = id.indexOf("/"); + if (slash <= 0 || slash === id.length - 1) throw new Error("model identity is invalid"); + return { provider: id.slice(0, slash), model: id.slice(slash + 1) }; +} diff --git a/backend/src/operator-command.ts b/backend/src/operator-command.ts index db820c45..2fd3bf74 100644 --- a/backend/src/operator-command.ts +++ b/backend/src/operator-command.ts @@ -8,8 +8,8 @@ import { join } from "node:path"; import { loadConfig, type AppConfig } from "./config.js"; import { rolesToPermissions } from "./auth/config.js"; import type { PrincipalContext } from "./auth/principal.js"; -import { createPiModelLister } from "./pi/list-models.js"; import { createPiManagement } from "./pi/management.js"; +import { loadRuntimeModelCatalog } from "./models/runtime-model-catalog.js"; import { effectiveSettings } from "./routes/settings.js"; import { MaintenanceBarrier } from "./runtime/maintenance-gate.js"; import { loadSettings } from "./settings/settings-store.js"; @@ -19,7 +19,7 @@ import { WorkspaceSecretStore } from "./workspaces/secret-store.js"; type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status" | "session-inventory" | "workflow-doctor" | "workspace-integrity" - | "pi-options" | "pi-test" | "effective-settings"; + | "pi-test" | "effective-settings"; const lifecyclePrincipal: PrincipalContext = { issuer: "tht-operator-command", @@ -110,9 +110,11 @@ export async function runOperatorAction( if (action === "session-inventory") return await sessionInventory(config); if (action === "workflow-doctor") return await workflowDiagnostics(config); if (action === "workspace-integrity") return await workspaceIntegrity(config); - if (action === "effective-settings") return effectiveSettings(config, loadSettings(config)); - const service = createPiManagement(config, { listModels: createPiModelLister(config) }); - if (action === "pi-options") return await service.options(); + const modelCatalog = loadRuntimeModelCatalog(config.modelCatalogFile); + if (action === "effective-settings") { + return effectiveSettings(config, loadSettings(config), modelCatalog); + } + const service = createPiManagement(config, { modelCatalog }); if (action === "pi-test") return await service.test(); throw new Error("unsupported operator action"); } @@ -121,7 +123,7 @@ async function main(): Promise { const action = process.argv[2] as OperatorAction | undefined; if (!action || ![ "maintenance-activate", "maintenance-deactivate", "maintenance-status", "session-inventory", - "workflow-doctor", "workspace-integrity", "pi-options", "pi-test", "effective-settings", + "workflow-doctor", "workspace-integrity", "pi-test", "effective-settings", ].includes(action)) throw new Error("invalid operator action"); const result = await runOperatorAction(action, loadConfig(process.env)); process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/backend/src/pi/list-models.ts b/backend/src/pi/list-models.ts index 6616221f..ebdafa55 100644 --- a/backend/src/pi/list-models.ts +++ b/backend/src/pi/list-models.ts @@ -18,6 +18,8 @@ export interface PiModel { reasoning: boolean; } +export type ListModelsFn = () => Promise; + interface Opts { spawnFn?: ( command: string, @@ -36,7 +38,7 @@ interface Opts { * configured) via an ephemeral `pi --mode rpc` process. Result is cached for * `ttlMs`. The returned function rejects on timeout/error; callers degrade. */ -export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise { +export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): ListModelsFn { const ttlMs = opts.ttlMs ?? 60_000; const now = opts.nowMs ?? (() => Date.now()); const spawnFn = opts.spawnFn ?? nodeSpawn; diff --git a/backend/src/pi/management.ts b/backend/src/pi/management.ts index 25efb0bf..5bba1fe9 100644 --- a/backend/src/pi/management.ts +++ b/backend/src/pi/management.ts @@ -2,12 +2,11 @@ import { execFile as nodeExecFile } from "node:child_process"; import { promisify } from "node:util"; import type { AppConfig } from "../config.js"; import { secretValue } from "../config/secret-bundle.js"; +import { loadSettings, type Settings } from "../settings/settings-store.js"; import { - loadSettings, - saveSettings, - type Settings, -} from "../settings/settings-store.js"; -import type { PiModel } from "./list-models.js"; + splitCanonicalModelId, + type RuntimeModelCatalog, +} from "../models/runtime-model-catalog.js"; import { configuredPiProviderApiKey, PI_MANAGED_CONFIG_ERROR_MESSAGE, @@ -45,13 +44,6 @@ export interface PiStatus { message?: string; } -export interface PiOptions { - providers: string[]; - models: Array<{ provider: string; id: string }>; - reasoning: PiReasoning[]; - checkedAt: string; -} - export interface PiTestResult { ready: boolean; checkedAt: string; @@ -76,15 +68,13 @@ export type PiExecFile = ( export interface PiManagementService { status(): Promise; - options(): Promise; - configure(value: PiInstallationConfig): Promise; test(): Promise; logs(): Promise; } export class PiManagementError extends Error { constructor( - public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed", + public readonly code: "pi_management_unavailable", message: string, ) { super(message); @@ -93,10 +83,9 @@ export class PiManagementError extends Error { interface PiManagementDeps { execute?: PiExecFile; - listModels: () => Promise; + modelCatalog: RuntimeModelCatalog; smokeProvider?: PiProviderSmoke; readSettings?: () => Settings; - saveSettings?: (settings: Settings) => Settings; readLogs?: () => string | Promise; credentialStatus?: (provider: string | undefined) => PiCredentialStatus; now?: () => Date; @@ -111,54 +100,36 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P }; const execute = deps.execute ?? defaultExecFile; const readSettings = deps.readSettings ?? (() => loadSettings(config)); - const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings)); const readLogs = deps.readLogs ?? (() => diagnostics.join("\n")); - const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config); + const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config, { + modelCatalog: deps.modelCatalog, + }); const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => { try { + const model = deps.modelCatalog.defaultSession + ? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultSession) + : undefined; + const credentialName = model?.authentication.mode === "secret_env" + ? model.authentication.apiKeyEnv + : undefined; + const configuredApiKey = configuredPiProviderApiKey( + readConfiguredPiAgentFile("models.json", true), + provider, + ) ?? (credentialName ? `$${credentialName}` : undefined); return piProviderCredentialStatus({ provider, authProviders: loadPiAuthProviders(), - resolveCredentialValue: () => secretValue(config, "THT_MODEL_API_KEY"), + resolveCredentialValue: () => credentialName + ? secretValue(config, credentialName) + : config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"), credentialFile: config.modelApiKeyFile, - configuredApiKey: configuredPiProviderApiKey( - readConfiguredPiAgentFile("models.json", true), - provider, - ), + configuredApiKey, }); } catch { return "missing"; } }); - const closedOptions = async (): Promise> => { - let listed: PiModel[]; - try { - listed = await deps.listModels(); - } catch (error) { - if (isPiManagedConfigError(error)) { - throw new PiManagementError("pi_management_unavailable", PI_MANAGED_CONFIG_ERROR_MESSAGE); - } - throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable"); - } - const models: Array<{ provider: string; id: string }> = []; - const providers: string[] = []; - const seenModels = new Set(); - const seenProviders = new Set(); - for (const model of listed) { - if (!isChoice(model?.provider) || !isChoice(model?.id)) continue; - const key = `${model.provider}\u0000${model.id}`; - if (seenModels.has(key)) continue; - seenModels.add(key); - models.push({ provider: model.provider, id: model.id }); - if (!seenProviders.has(model.provider)) { - seenProviders.add(model.provider); - providers.push(model.provider); - } - } - return { providers, models, reasoning: [...REASONING_CHOICES] }; - }; - const version = async (timeoutMs = config.piManagementTimeoutMs): Promise => { let output: { stdout: string; stderr: string }; try { @@ -180,12 +151,12 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P const installationConfig = (): PiInstallationConfig => { const settings = readSettings(); - const provider = config.defaults.provider ?? settings.provider; - const model = config.defaults.model ?? settings.model; const reasoning = config.defaults.thinking ?? settings.thinking; + const selected = deps.modelCatalog.defaultSession + ? splitCanonicalModelId(deps.modelCatalog.defaultSession) + : undefined; return { - ...(isChoice(provider) ? { provider } : {}), - ...(isChoice(model) ? { model } : {}), + ...(selected ? selected : {}), ...(isReasoning(reasoning) ? { reasoning } : {}), }; }; @@ -206,28 +177,6 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P } }, - async options(): Promise { - const choices = await closedOptions(); - return { ...choices, checkedAt: now().toISOString() }; - }, - - async configure(value: PiInstallationConfig): Promise { - if (!isInstallationConfig(value)) { - throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid"); - } - const choices = await closedOptions(); - if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) { - throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices"); - } - try { - persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning }); - } catch { - throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved"); - } - addDiagnostic("Pi installation defaults updated"); - return { ...value, updatedAt: now().toISOString() }; - }, - async test(): Promise { const checkedAt = now().toISOString(); const deadline = Date.now() + config.piManagementTimeoutMs; @@ -293,24 +242,10 @@ async function defaultExecFile(command: string, args: string[], options: PiExecF return { stdout: String(result.stdout), stderr: String(result.stderr) }; } -function isChoice(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value - && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value); -} - function isReasoning(value: unknown): value is PiReasoning { return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value); } -function isInstallationConfig(value: unknown): value is Required { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const candidate = value as Record; - if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) { - return false; - } - return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning); -} - function isTimeout(error: unknown): boolean { return Boolean( error && typeof error === "object" && ( diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index ee5d7b6f..08da76de 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -11,6 +11,10 @@ import { configuredPiProviderApiKey, createPiRuntimeAgentSnapshot, } from "./managed-config.js"; +import { + loadRuntimeModelCatalog, + type RuntimeModelCatalog, +} from "../models/runtime-model-catalog.js"; export interface SessionRuntime { rpc: RpcClient; @@ -42,23 +46,32 @@ export class PiProcessManager { private runtimes = new Map(); private agentSnapshotCleanups = new WeakMap void>(); private spawnFn: ( - sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext, - runtimeConfigPath?: string, + sessionId: string, author: string, provider: string | undefined, model: string | undefined, + principal?: PrincipalContext, runtimeConfigPath?: string, ) => ChildProcessWithoutNullStreams; private loadAuthProviders: (agentDir: string) => ReadonlySet; + private modelCatalog: RuntimeModelCatalog; + private modelCatalogConfigured: boolean; constructor( private cfg: AppConfig, - opts?: { spawnFn?: SpawnFn; authProviders?: (agentDir: string) => ReadonlySet }, + opts?: { + spawnFn?: SpawnFn; + authProviders?: (agentDir: string) => ReadonlySet; + modelCatalog?: RuntimeModelCatalog; + }, ) { + this.modelCatalog = opts?.modelCatalog ?? loadRuntimeModelCatalog(cfg.modelCatalogFile); + this.modelCatalogConfigured = cfg.modelCatalogFile !== undefined + || this.modelCatalog.defaultSession !== null; this.loadAuthProviders = opts?.authProviders ?? ((agentDir) => loadPiAuthProviders({ agentDir })); if (opts?.spawnFn) { - this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) => - this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal, runtimeConfigPath); + this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) => + this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath); } else { - this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) => - this.spawnPi(nodeSpawn, sessionId, author, provider, principal, runtimeConfigPath); + this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) => + this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath); } } @@ -71,7 +84,7 @@ export class PiProcessManager { private spawnPi( spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, - principal?: PrincipalContext, runtimeConfigPath?: string, + model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string, ): ChildProcessWithoutNullStreams { // This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate // before auth-provider inspection, then make Pi consume the exact copied bytes rather than @@ -79,12 +92,23 @@ export class PiProcessManager { const agent = createPiRuntimeAgentSnapshot(); let child: ChildProcessWithoutNullStreams | undefined; try { + const catalogModel = provider && model + ? this.modelCatalog.sessionModels() + .find((entry) => entry.provider === provider && entry.model === model) + : undefined; + const credentialName = catalogModel?.authentication.mode === "secret_env" + ? catalogModel.authentication.apiKeyEnv + : undefined; + const projectedApiKey = configuredPiProviderApiKey(agent.models, provider) + ?? (credentialName ? `$${credentialName}` : undefined); const env = buildPiChildEnv({ provider, authProviders: this.loadAuthProviders(agent.agentDir), - credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"), + credentialValue: credentialName + ? secretValue(this.cfg, credentialName) + : this.modelCatalogConfigured ? undefined : secretValue(this.cfg, "THT_MODEL_API_KEY"), credentialFile: this.cfg.modelApiKeyFile, - configuredApiKey: configuredPiProviderApiKey(agent.models, provider), + configuredApiKey: projectedApiKey, additions: { THT_SESSION: sessionId, THT_AUTHOR: author }, }); env.PI_CODING_AGENT_DIR = agent.agentDir; @@ -162,9 +186,10 @@ export class PiProcessManager { } const author = o.author ?? "dev@local"; const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider); + const model = o.model ?? this.cfg.defaults.model; let child: ChildProcessWithoutNullStreams; try { - child = this.spawnFn(sessionId, author, provider, o.principal, o.runtimeConfig?.path); + child = this.spawnFn(sessionId, author, provider, model, o.principal, o.runtimeConfig?.path); } catch (error) { o.runtimeConfig?.release(); throw error; diff --git a/backend/src/pi/provider-smoke.ts b/backend/src/pi/provider-smoke.ts index 19c5cbd2..3def9147 100644 --- a/backend/src/pi/provider-smoke.ts +++ b/backend/src/pi/provider-smoke.ts @@ -17,6 +17,10 @@ import { readConfiguredPiAgentFile, validateDeclarativePiConfig, } from "./managed-config.js"; +import { + loadRuntimeModelCatalog, + type RuntimeModelCatalog, +} from "../models/runtime-model-catalog.js"; const SMOKE_PROMPT = "Provider health check. Reply with exactly OK."; const SMOKE_ARGS = [ @@ -49,6 +53,7 @@ interface ProviderSmokeOptions { authProviders?: () => ReadonlySet; readAuthStore?: () => string; readModelsStore?: () => string | undefined; + modelCatalog?: RuntimeModelCatalog; } export function createPiProviderSmoke( @@ -69,12 +74,24 @@ export function createPiProviderSmoke( const configuredModels = options.readModelsStore ? options.readModelsStore() : readConfiguredPiAgentFile("models.json", true); + const catalog = options.modelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile); + const catalogConfigured = config.modelCatalogFile !== undefined + || catalog.defaultSession !== null; + const catalogModel = catalog.sessionModels() + .find((entry) => entry.provider === canonicalProvider && entry.model === model); + const credentialName = catalogModel?.authentication.mode === "secret_env" + ? catalogModel.authentication.apiKeyEnv + : undefined; + const projectedApiKey = configuredPiProviderApiKey(configuredModels, canonicalProvider) + ?? (credentialName ? `$${credentialName}` : undefined); const env = buildPiChildEnv({ provider: canonicalProvider, authProviders: configuredAuthProviders, - credentialValue: secretValue(config, "THT_MODEL_API_KEY"), + credentialValue: credentialName + ? secretValue(config, credentialName) + : catalogConfigured ? undefined : secretValue(config, "THT_MODEL_API_KEY"), credentialFile: config.modelApiKeyFile, - configuredApiKey: configuredPiProviderApiKey(configuredModels, canonicalProvider), + configuredApiKey: projectedApiKey, }); clearPrincipalEnvironment(env); delete env.THT_DATA_ROOT; diff --git a/backend/src/routes/catalog-description-generation.ts b/backend/src/routes/catalog-description-generation.ts index fc45f944..d165dd2f 100644 --- a/backend/src/routes/catalog-description-generation.ts +++ b/backend/src/routes/catalog-description-generation.ts @@ -32,7 +32,7 @@ import { } from "../catalog/types.js"; const idSchema = z.uuid(); -const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/); +const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/); const selectedTargetIdsSchema = z.array(idSchema).min(1); const suggestionSchema = z.discriminatedUnion("scope", [ z.object({ modelId: modelIdSchema, scope: z.literal("all") }).strict(), diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index 018536a3..95b975d3 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -1,10 +1,8 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import type { FastifyInstance } from "fastify"; -import type { PiModel } from "../pi/list-models.js"; import { isPrincipalContext, requirePermission } from "../auth/authorization.js"; - -export type ListModelsFn = () => Promise; +import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js"; /** * List YAML workspace configs found in /workspaces/*.yaml. @@ -25,20 +23,17 @@ export function listWorkspaces(harnessDir: string): { name: string; file: string export function metaRoutes( app: FastifyInstance, - deps: { harnessDir: string; listModels?: ListModelsFn }, + deps: { harnessDir: string; modelCatalog: RuntimeModelCatalog }, ): void { app.get("/models", async (request, reply) => { if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply; - const fn = deps.listModels ?? (async () => []); - try { - return { models: await fn() }; - } catch (error) { - app.log.warn({ - component: "pi-model-list", - errorType: error instanceof Error ? error.name : typeof error, - }, "Pi model listing failed"); - // Graceful fallback: Pi may not be running; don't crash the server. - return { models: [] as PiModel[] }; - } + return { + models: deps.modelCatalog.sessionModels().map((entry) => ({ + provider: entry.provider, + id: entry.model, + name: entry.label, + reasoning: entry.session?.reasoning ?? false, + })), + }; }); } diff --git a/backend/src/routes/pi-management.ts b/backend/src/routes/pi-management.ts index 8a3838e6..cea65a51 100644 --- a/backend/src/routes/pi-management.ts +++ b/backend/src/routes/pi-management.ts @@ -11,13 +11,6 @@ export function piManagementRoutes( deps: { service: PiManagementService }, ): void { app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status())); - app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options())); - app.put("/pi-management/config", async (request, reply) => run( - request, - reply, - deps, - () => deps.service.configure((request.body ?? {}) as Record), - )); app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test())); app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs())); } @@ -38,8 +31,7 @@ async function run( return await action(); } catch (error) { if (error instanceof PiManagementError) { - const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503; - return reply.code(statusCode).send({ code: error.code, error: error.message }); + return reply.code(503).send({ code: error.code, error: error.message }); } return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" }); } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 6ca529ca..7dc59989 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -6,12 +6,13 @@ import type { Settings } from "../settings/settings-store.js"; import { getPrincipal } from "../auth/auth.js"; import type { PrincipalContext } from "../auth/principal.js"; import type { ReadinessManager } from "../runtime/readiness-manager.js"; -import type { ListModelsFn } from "./meta.js"; +import type { ListModelsFn } from "../pi/list-models.js"; import type { WorkspaceRegistry } from "../workspaces/registry.js"; import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js"; import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js"; import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js"; import type { EffectiveRelationshipSnapshotProvider } from "../catalog/effective-relationship-snapshot.js"; +import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js"; const BOOTSTRAP_FAILURE_MESSAGE = "Session startup failed. Check configuration and connectivity, then Resume the session."; @@ -43,6 +44,7 @@ export function sessionRoutes( maintenanceBarrier: MaintenanceBarrier; /** Optional only for narrow route-test stubs and installations without a Catalog database. */ effectiveRelationships?: EffectiveRelationshipSnapshotProvider; + modelCatalog: RuntimeModelCatalog; }, ) { const lifecycleTails = new Map>(); @@ -358,7 +360,6 @@ export function sessionRoutes( let workspaceId: string | undefined; let workspaceRevision: string | undefined; let workspaceDescriptor: WorkspaceDescriptor | undefined; - let allowedModels: readonly string[] | undefined; if (requestedWorkspaceId) { try { const registry = d.workspaceRegistry as Partial; @@ -378,7 +379,6 @@ export function sessionRoutes( workspaceId = resolved.revision.id; workspaceRevision = resolved.revision.commit; workspaceDescriptor = resolved.workspace; - allowedModels = resolved.workspace.llm_policy.allowed; } catch { return reply.code(409).send({ error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, @@ -386,12 +386,17 @@ export function sessionRoutes( }); } } - const provider = b.provider ?? s.provider; - const model = b.model ?? s.model; + const requestedCanonical = b.provider && b.model ? `${b.provider}/${b.model}` : undefined; + let selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultSession; + let modelWarning: string | undefined; + if (selectedCanonical && d.modelCatalog.defaultSession && !d.modelCatalog.hasSession(selectedCanonical)) { + selectedCanonical = d.modelCatalog.defaultSession; + modelWarning = `Configured model ${requestedCanonical ?? "selection"} is unavailable; using ${selectedCanonical}.`; + } + const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined; + const provider = selected?.provider ?? b.provider; + const model = selected?.model ?? b.model; const thinking = b.thinking ?? s.thinking; - if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) { - return reply.code(400).send({ error: "Selected model is not allowed by this workspace." }); - } // A persisted session is resumable without keeping Pi alive. New work replaces every // runtime owned by this principal, while runtimes belonging to other users remain intact. // Optional chaining preserves the deliberately narrow manager stubs used by route tests. @@ -490,7 +495,7 @@ export function sessionRoutes( ), () => d.mgr.start(id, rt, runtimeOptions), ); - return { id }; + return { id, ...(modelWarning ? { warning: modelWarning } : {}) }; } finally { if (revisionLease && !manifestPersisted) { await revisionLease.abort().catch((error: unknown) => { @@ -598,6 +603,10 @@ export function sessionRoutes( provider?: string; model?: string; thinking?: string; workspace_id?: string; workspace_revision?: string; }; + const savedCanonical = saved.provider && saved.model ? `${saved.provider}/${saved.model}` : ""; + if (d.modelCatalog.defaultSession && (!savedCanonical || !d.modelCatalog.hasSession(savedCanonical))) { + return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" }); + } let workspaceConfigPath: string; let workspaceDescriptor: WorkspaceDescriptor | undefined; try { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 71afd333..9da6b146 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,17 +1,27 @@ import type { FastifyInstance } from "fastify"; import type { AppConfig } from "../config.js"; import type { Settings } from "../settings/settings-store.js"; -import { listWorkspaces, type ListModelsFn } from "./meta.js"; +import { listWorkspaces } from "./meta.js"; import type { PrincipalContext } from "../auth/principal.js"; import { isPrincipalContext, requirePermission } from "../auth/authorization.js"; +import { + splitCanonicalModelId, + type RuntimeModelCatalog, +} from "../models/runtime-model-catalog.js"; -/** Merge stored settings over env/first-workspace defaults. */ -export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings { +/** Merge only workspace and runtime-thinking preferences; model defaults belong to modelCatalog. */ +export function effectiveSettings( + cfg: AppConfig, + stored: Settings, + modelCatalog?: RuntimeModelCatalog, +): Settings { const workspaces = listWorkspaces(cfg.harnessDir); + const selected = modelCatalog?.defaultSession + ? splitCanonicalModelId(modelCatalog.defaultSession) + : undefined; return { workspace: stored.workspace ?? workspaces[0]?.name, - provider: cfg.defaults.provider ?? stored.provider, - model: cfg.defaults.model ?? stored.model, + ...(selected ?? {}), thinking: cfg.defaults.thinking ?? stored.thinking, }; } @@ -19,7 +29,7 @@ export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings { export function settingsRoutes( app: FastifyInstance, deps: { - cfg: AppConfig; listModels: ListModelsFn; + cfg: AppConfig; getSettings: (principal: PrincipalContext) => Promise; }, ): void { @@ -37,22 +47,6 @@ export function settingsRoutes( const principal = requirePermission(req, reply, "settings.manage"); if (!isPrincipalContext(principal)) return principal; const b = (req.body ?? {}) as Settings; - if (b.model) { - let available: { provider: string; id: string }[] = []; - try { - available = await deps.listModels(); - } catch { - available = []; - } - // Only validate when Pi gave us a non-empty list; otherwise allow (degraded). - if (available.length > 0 && !available.some( - (candidate) => candidate.provider === b.provider && candidate.id === b.model, - )) { - return reply.code(400).send({ - error: `Unknown model: ${b.provider ?? "unknown"}/${b.model}`, - }); - } - } try { // Retain this endpoint as a validating compatibility surface for older clients, but do // not write anonymous users' choices to shared server storage. diff --git a/backend/src/settings/settings-store.ts b/backend/src/settings/settings-store.ts index d42792f5..b2315bd5 100644 --- a/backend/src/settings/settings-store.ts +++ b/backend/src/settings/settings-store.ts @@ -13,6 +13,7 @@ import type { AppConfig } from "../config.js"; export interface Settings { workspace?: string; + /** Legacy input fields are ignored when loading/evaluating installation settings. */ provider?: string; model?: string; thinking?: string; @@ -37,7 +38,13 @@ export function loadSettings(cfg: AppConfig): Settings { try { const raw = readFileSync(cfg.settingsFile, "utf8"); const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") return parsed as Settings; + if (parsed && typeof parsed === "object") { + const value = parsed as Record; + return { + ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), + ...(typeof value.thinking === "string" ? { thinking: value.thinking } : {}), + }; + } return {}; } catch { return {}; diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 2074bdfd..a474f342 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -598,7 +598,11 @@ export class ThtRunner { } catch { return { ok: false, code: "workspace_not_activatable" }; } - const collection = descriptor.semantic_index.vector_store; + const collection = { + collection: descriptor.workspace.id, + dimensions: this.cfg.semanticRuntime.internalEmbeddingDimensions, + distance: "cosine" as const, + }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutSec) * 1000); try { diff --git a/backend/src/workspace-maintenance.ts b/backend/src/workspace-maintenance.ts index 8a466eae..03078e2e 100644 --- a/backend/src/workspace-maintenance.ts +++ b/backend/src/workspace-maintenance.ts @@ -238,6 +238,7 @@ function createProductionService(): WorkspacePreprocessingService { }); return new WorkspacePreprocessingService({ dataRoot: config.dataRoot ?? "/data", + embeddingDimensions: config.internalEmbeddingDimensions, httpPrivateHostAllowlist: (process.env.THT_EVIDENCE_PRIVATE_HOST_ALLOWLIST ?? "") .split(",").map((value) => value.trim()).filter((value) => value.length > 0), acquireActiveRuntime: async (workspaceId) => { diff --git a/backend/src/workspaces/bindings.ts b/backend/src/workspaces/bindings.ts index 9d74f7e0..e9ed7da6 100644 --- a/backend/src/workspaces/bindings.ts +++ b/backend/src/workspaces/bindings.ts @@ -59,12 +59,12 @@ function safeSecretFilePath(path: string, secretRoots: readonly string[]): strin function requireSupportedDescriptor(workspace: unknown): void { if (typeof workspace !== "object" || workspace === null) { - throw new Error("Workspace bindings support only workspace schema version 3"); + throw new Error("Workspace bindings support only workspace schema version 4"); } const metadata = Reflect.get(workspace, "workspace"); if (typeof metadata !== "object" || metadata === null - || Reflect.get(metadata, "schema_version") !== 3) { - throw new Error("Workspace bindings support only workspace schema version 3"); + || Reflect.get(metadata, "schema_version") !== 4) { + throw new Error("Workspace bindings support only workspace schema version 4"); } } @@ -155,7 +155,7 @@ export function resolveEvidenceBinding( return { values, missing }; } -/** Resolve the complete schema-v3 runtime binding set. */ +/** Resolve the complete schema-v4 runtime binding set. */ export function resolveRuntimeBindings( workspace: WorkspaceDescriptor, env: NodeJS.ProcessEnv, diff --git a/backend/src/workspaces/contracts.ts b/backend/src/workspaces/contracts.ts index e13baf17..19da83d2 100644 --- a/backend/src/workspaces/contracts.ts +++ b/backend/src/workspaces/contracts.ts @@ -137,12 +137,12 @@ function evidenceVariables( function requireSupportedDescriptor(workspace: unknown): void { if (typeof workspace !== "object" || workspace === null) { - throw new Error("Installation contract supports only workspace schema version 3"); + throw new Error("Installation contract supports only workspace schema version 4"); } const metadata = Reflect.get(workspace, "workspace"); if (typeof metadata !== "object" || metadata === null - || Reflect.get(metadata, "schema_version") !== 3) { - throw new Error("Installation contract supports only workspace schema version 3"); + || Reflect.get(metadata, "schema_version") !== 4) { + throw new Error("Installation contract supports only workspace schema version 4"); } } diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index 2921587f..fdf4fc90 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -406,12 +406,12 @@ function numericBinding(binding: Record, name: string): number | function requireSupportedDescriptor(workspace: unknown): void { if (typeof workspace !== "object" || workspace === null) { - throw new Error("Workspace diagnoser supports only workspace schema version 3"); + throw new Error("Workspace diagnoser supports only workspace schema version 4"); } const metadata = Reflect.get(workspace, "workspace"); if (typeof metadata !== "object" || metadata === null - || Reflect.get(metadata, "schema_version") !== 3) { - throw new Error("Workspace diagnoser supports only workspace schema version 3"); + || Reflect.get(metadata, "schema_version") !== 4) { + throw new Error("Workspace diagnoser supports only workspace schema version 4"); } } @@ -506,11 +506,15 @@ async function diagnoseValidatedWorkspace( try { const vector = await withTimeout(timeoutMs, (signal) => adapters.inspectQdrant({ baseUrl: semanticRuntime.internalQdrantUrl, - collection: descriptor.semantic_index.vector_store.collection, + collection: descriptor.workspace.id, timeoutMs, signal, })); - const expected = descriptor.semantic_index.vector_store; + const expected = { + collection: descriptor.workspace.id, + dimensions: semanticRuntime.internalEmbeddingDimensions, + distance: "cosine", + }; if (vector.collection !== expected.collection || vector.dimensions !== expected.dimensions || vector.distance !== expected.distance) { @@ -529,10 +533,8 @@ async function diagnoseValidatedWorkspace( timeoutMs, signal, })); - if (semanticRuntime.internalEmbeddingModel !== descriptor.semantic_index.embedding.model - || semanticRuntime.internalEmbeddingDimensions !== descriptor.semantic_index.embedding.dimensions - || !embedding.available - || embedding.dimensions !== descriptor.semantic_index.embedding.dimensions) { + if (!embedding.available + || embedding.dimensions !== semanticRuntime.internalEmbeddingDimensions) { diagnostics.push(diagnosticError("semantic_index_incompatible")); activatable = false; } diff --git a/backend/src/workspaces/effective-config.ts b/backend/src/workspaces/effective-config.ts index 29e06bf1..031bb17e 100644 --- a/backend/src/workspaces/effective-config.ts +++ b/backend/src/workspaces/effective-config.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { normalize } from "node:path"; export interface CanonicalEffectiveConfig { - schemaVersion: 1; + schemaVersion: 2; dwh: CanonicalDwhConfig; vector: CanonicalVectorConfig; embedding: CanonicalEmbeddingConfig; @@ -27,6 +27,7 @@ export interface CanonicalVectorConfig { } export interface CanonicalEmbeddingConfig { + id: string; model: string; dimensions: number; } @@ -137,8 +138,10 @@ function buildEmbeddingConfig(rendered: Record): CanonicalEmbed if (!embeddings) { throw new TypeError("effective config is missing embedding resources"); } + const model = requireString(embeddings, "model"); return { - model: requireString(embeddings, "model"), + id: `ollama/${model}`, + model, dimensions: requireNumber(embeddings, "dimensions"), }; } @@ -166,7 +169,7 @@ export function buildCanonicalEffectiveConfig(renderedConfig: unknown): Canonica throw new TypeError("effective config requires a rendered configuration object"); } return { - schemaVersion: 1, + schemaVersion: 2, dwh: buildDwhConfig(rendered), vector: buildVectorConfig(rendered), embedding: buildEmbeddingConfig(rendered), diff --git a/backend/src/workspaces/preprocessing-service.ts b/backend/src/workspaces/preprocessing-service.ts index f7dbc4f0..767d63fe 100644 --- a/backend/src/workspaces/preprocessing-service.ts +++ b/backend/src/workspaces/preprocessing-service.ts @@ -77,6 +77,7 @@ export interface WorkspacePreprocessingServiceDeps { { ok: true } | { ok: false; code: "workspace_not_activatable" | "semantic_index_incompatible" } >; httpPrivateHostAllowlist?: readonly string[]; + embeddingDimensions?: number; } interface RunScope { @@ -118,7 +119,7 @@ export class WorkspacePreprocessingService { async vectorInspect(options: { workspaceId: string }): Promise { const runtime = await this.deps.acquireActiveRuntime(options.workspaceId); - const collection = runtime.workspace.semantic_index.vector_store.collection; + const collection = runtime.workspace.workspace.id; const res = await fetch(`${runtime.configLease.semanticQdrantUrl}/collections/${encodeURIComponent(collection)}`, { method: "GET" }); if (!res.ok) return baseResult(runtime, "vector inspect", "failed", "semantic_index_incompatible", { warnings: ["collection unavailable"] }); const body = await res.json() as any; @@ -132,7 +133,7 @@ export class WorkspacePreprocessingService { async vectorRebuild(options: { workspaceId: string; collection?: string; confirm?: string; destroy?: boolean }): Promise { const runtime = await this.deps.acquireActiveRuntime(options.workspaceId); - const collection = runtime.workspace.semantic_index.vector_store.collection; + const collection = runtime.workspace.workspace.id; if (options.collection !== collection || options.confirm !== collection || options.destroy !== true) { return baseResult(runtime, "vector rebuild", "failed", "semantic_index_incompatible", { warnings: ["rebuild requires exact confirmation and --destroy"] }); } @@ -143,8 +144,8 @@ export class WorkspacePreprocessingService { const recreated = await reconcileCollection({ baseUrl: runtime.configLease.semanticQdrantUrl, collection, - dimensions: runtime.workspace.semantic_index.vector_store.dimensions, - distance: runtime.workspace.semantic_index.vector_store.distance, + dimensions: this.deps.embeddingDimensions ?? 1024, + distance: "cosine", mode: "self_heal", }); if (!recreated.ok) return baseResult(runtime, "vector rebuild", "failed", "semantic_index_incompatible", { warnings: ["collection recreate failed"] }); @@ -400,6 +401,8 @@ export class WorkspacePreprocessingService { catalogBlob: runtime.catalogBlob, configDigest: runtime.configLease.configDigest, bindingDigest: runtime.configLease.bindingDigest, + embeddingId: runtime.configLease.effectiveConfig.embedding.id, + embeddingDimensions: runtime.configLease.effectiveConfig.embedding.dimensions, }); return { runtime, state, job }; } diff --git a/backend/src/workspaces/preprocessing-state.ts b/backend/src/workspaces/preprocessing-state.ts index f950c4ef..78245abb 100644 --- a/backend/src/workspaces/preprocessing-state.ts +++ b/backend/src/workspaces/preprocessing-state.ts @@ -44,11 +44,13 @@ export interface BeginPreprocessingJobOptions { catalogBlob: string; configDigest: string; bindingDigest: string; + embeddingId: string; + embeddingDimensions: number; runId?: string; } export interface PreprocessingJobState { - schemaVersion: 1; + schemaVersion: 2; runId: string; operation: string; workspaceId: string; @@ -57,6 +59,8 @@ export interface PreprocessingJobState { catalogBlob: string; configDigest: string; bindingDigest: string; + embeddingId: string; + embeddingDimensions: number; completedStages: string[]; childRuns: Record; status: "active" | "succeeded" | "blocked" | "failed"; @@ -146,7 +150,7 @@ function decodeJob(value: unknown): PreprocessingJobState { } const record = value as Record; if ( - record.schemaVersion !== 1 + record.schemaVersion !== 2 || typeof record.runId !== "string" || typeof record.operation !== "string" || typeof record.workspaceId !== "string" @@ -155,6 +159,8 @@ function decodeJob(value: unknown): PreprocessingJobState { || typeof record.catalogBlob !== "string" || typeof record.configDigest !== "string" || typeof record.bindingDigest !== "string" + || typeof record.embeddingId !== "string" + || typeof record.embeddingDimensions !== "number" || !Array.isArray(record.completedStages) || typeof record.childRuns !== "object" || record.childRuns === null || Array.isArray(record.childRuns) || !["active", "succeeded", "blocked", "failed"].includes(String(record.status)) @@ -275,6 +281,8 @@ export class PreprocessingStateStore { || existing.catalogBlob !== options.catalogBlob || existing.configDigest !== options.configDigest || existing.bindingDigest !== options.bindingDigest + || existing.embeddingId !== options.embeddingId + || existing.embeddingDimensions !== options.embeddingDimensions ) { throw new PreprocessingStateError( "preprocessing_resume_mismatch", @@ -295,7 +303,7 @@ export class PreprocessingStateStore { } } const job: PreprocessingJobState = { - schemaVersion: 1, + schemaVersion: 2, runId, operation: options.operation, workspaceId: this.options.workspaceId, @@ -304,6 +312,8 @@ export class PreprocessingStateStore { catalogBlob: options.catalogBlob, configDigest: options.configDigest, bindingDigest: options.bindingDigest, + embeddingId: options.embeddingId, + embeddingDimensions: options.embeddingDimensions, completedStages: [], childRuns: {}, status: "active", diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index f2f4fd1c..2ce2f051 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -560,7 +560,7 @@ export class WorkspaceRegistry { }); } } - const collection = workspace.semantic_index.vector_store.collection; + const collection = workspace.workspace.id; const owner = collectionOwners.get(collection); if (owner !== undefined) { throw new Error(`duplicate qdrant collection ownership: ${collection} (${owner}, ${id})`); diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index e1b3c5b0..0a9f4652 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -34,6 +34,7 @@ export interface RuntimeInstallationOverlay { export interface SemanticRuntimeConfig { internalQdrantUrl: string; internalEmbeddingUrl: string; + internalEmbeddingId?: string; internalEmbeddingModel: string; internalEmbeddingDimensions: number; } @@ -41,6 +42,7 @@ export interface SemanticRuntimeConfig { export const DEFAULT_SEMANTIC_RUNTIME: SemanticRuntimeConfig = { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", + internalEmbeddingId: "ollama/qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, }; @@ -202,16 +204,16 @@ function placeholderConnection(identity: { database: string; schema: string }): function requireSupportedDescriptor(workspace: unknown): void { if (typeof workspace !== "object" || workspace === null) { - throw new Error("Runtime renderer supports only workspace schema version 3"); + throw new Error("Runtime renderer supports only workspace schema version 4"); } const metadata = Reflect.get(workspace, "workspace"); if (typeof metadata !== "object" || metadata === null - || Reflect.get(metadata, "schema_version") !== 3) { - throw new Error("Runtime renderer supports only workspace schema version 3"); + || Reflect.get(metadata, "schema_version") !== 4) { + throw new Error("Runtime renderer supports only workspace schema version 4"); } } -/** Render the schema-v3 compatibility fields consumed by the current Python harness. */ +/** Render the schema-v4 compatibility fields consumed by the current Python harness. */ export function renderRuntimeConfig( workspace: WorkspaceDescriptor, bindings: RuntimeBindings, @@ -263,12 +265,24 @@ export function renderRuntimeConfig( ...(installation.profile === undefined ? {} : { profile: installation.profile }), language: descriptor.workspace.language, database, - semantic_index: descriptor.semantic_index, + semantic_index: { + vector_store: { + engine: "qdrant", + collection: descriptor.workspace.id, + dimensions: semanticRuntime.internalEmbeddingDimensions, + distance: "cosine", + }, + embedding: { + provider: "ollama_internal", + model: semanticRuntime.internalEmbeddingModel, + dimensions: semanticRuntime.internalEmbeddingDimensions, + }, + }, resources: { vector: { engine: "qdrant", base_url: semanticRuntime.internalQdrantUrl, - collection: descriptor.semantic_index.vector_store.collection, + collection: descriptor.workspace.id, }, embeddings: { provider: "ollama_internal", diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index 66c5363c..88dcb24c 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -35,7 +35,7 @@ export interface CanonicalDiagnostics { } interface WorkspaceMetadata { - schema_version: 3; + schema_version: 4; id: string; name: string; description?: string; @@ -51,31 +51,12 @@ interface WorkspaceDwh { supported_transports: DwhTransport[]; } -interface WorkspaceBase { +interface WorkspaceBase { workspace: WorkspaceMetadata; dwh: WorkspaceDwh; - semantic_index: { - vector_store: TVectorStore; - embedding: { - provider: "ollama_internal"; - model: "qwen3-embedding:0.6b"; - dimensions: 1024; - }; - }; - llm_policy: { - default?: `${string}/${string}`; - allowed: `${string}/${string}`[]; - }; diagnostics?: Pick; } -interface QdrantVectorStore { - engine: "qdrant"; - collection: string; - dimensions: 1024; - distance: "cosine"; -} - export interface EvidencePolicy { max_chunk_chars: number; retain_published_generations: number; @@ -120,12 +101,12 @@ export interface WorkspaceEvidence { policy: EvidencePolicy; } -export interface WorkspaceV3 extends WorkspaceBase { +export interface WorkspaceV4 extends WorkspaceBase { evidence?: WorkspaceEvidence; } -export type CanonicalWorkspace = WorkspaceV3; -export type WorkspaceDescriptor = WorkspaceV3; +export type CanonicalWorkspace = WorkspaceV4; +export type WorkspaceDescriptor = WorkspaceV4; const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/, { message: "workspace id must match ^[a-z][a-z0-9-]{2,62}$", @@ -135,9 +116,6 @@ const identifier = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { }); const port = z.number().int().min(1).max(65_535); const timeoutMs = z.number().int().positive(); -const modelReference = z.string().regex(/^[^/\s]+\/[^/\s]+$/, { - message: "model must use provider/model syntax", -}); function isOriginRelativeDiagnosticPath(value: string): boolean { return /^\/(?!\/)[^\\\u0000-\u001F\u007F?#]*$/.test(value) && !/%5c/i.test(value); @@ -166,21 +144,6 @@ const dwhSchema = z.object({ timeout_ms: timeoutMs.optional(), supported_transports: z.array(z.enum(DWH_TRANSPORTS)).min(1), }).strict(); -const internalEmbeddingSchema = z.object({ - provider: z.literal("ollama_internal"), - model: z.literal("qwen3-embedding:0.6b"), - dimensions: z.literal(1024), -}).strict(); -const qdrantVectorStoreSchema = z.object({ - engine: z.literal("qdrant"), - collection: workspaceId, - dimensions: z.literal(1024), - distance: z.literal("cosine"), -}).strict(); -const llmPolicySchema = z.object({ - default: modelReference.optional(), - allowed: z.array(modelReference).min(1), -}).strict(); const positiveSafeInteger = z.number().int().safe().positive(); const nonnegativeSafeInteger = z.number().int().safe().nonnegative(); @@ -366,7 +329,6 @@ function unique(values: readonly T[], context: z.RefinementCtx, path: Propert function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { unique(workspace.dwh.supported_transports, context, ["dwh", "supported_transports"]); - unique(workspace.llm_policy.allowed, context, ["llm_policy", "allowed"]); if (workspace.evidence?.source.type === "filesystem") { const expected = `${workspace.workspace.id}/evidence`; @@ -379,20 +341,6 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { } } - if (workspace.semantic_index.vector_store.dimensions !== workspace.semantic_index.embedding.dimensions) { - context.addIssue({ - code: "custom", - path: ["semantic_index", "embedding", "dimensions"], - message: "embedding dimensions must match vector store dimensions", - }); - } - if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) { - context.addIssue({ - code: "custom", - path: ["llm_policy", "default"], - message: "LLM default must be included in the allowlist", - }); - } if (workspace.diagnostics?.dwh_rest && !workspace.dwh.supported_transports.includes("rest_api")) { context.addIssue({ code: "custom", @@ -402,23 +350,18 @@ function workspaceInvariants(workspace: any, context: z.RefinementCtx): void { } } -const WorkspaceV3Schema = z.object({ +const WorkspaceV4Schema = z.object({ dwh: dwhSchema, - llm_policy: llmPolicySchema, evidence: workspaceEvidenceSchema.optional(), diagnostics: z.object({ dwh_rest: dwhRestDiagnostic.optional(), }).strict().optional(), workspace: z.object({ - schema_version: z.literal(3), id: workspaceId, name: z.string().trim().min(1), + schema_version: z.literal(4), id: workspaceId, name: z.string().trim().min(1), description: z.string().trim().min(1).optional(), language: z.enum(["en", "it"]), }).strict(), - semantic_index: z.object({ - vector_store: qdrantVectorStoreSchema, - embedding: internalEmbeddingSchema, - }).strict(), }).strict().superRefine(workspaceInvariants); -const WorkspaceDescriptorSchema = WorkspaceV3Schema; +const WorkspaceDescriptorSchema = WorkspaceV4Schema; export function parseWorkspaceYaml(source: string): WorkspaceDescriptor { const documents = parseAllDocuments(source, { uniqueKeys: true }); @@ -431,6 +374,25 @@ export function parseWorkspaceYaml(source: string): WorkspaceDescriptor { return validateWorkspaceDescriptor(document.toJSON()); } +/** Deterministically removes the two installation-owned v3 blocks without altering workspace data. */ +export function migrateWorkspaceV3Yaml(source: string): string { + const documents = parseAllDocuments(source, { uniqueKeys: true }); + if (documents.length !== 1) throw new Error("Workspace YAML must contain exactly one document"); + const document = documents[0]; + if (document.errors.length > 0 || document.warnings.length > 0) { + throw new Error("Invalid workspace YAML"); + } + const value = document.toJSON() as Record; + const metadata = value.workspace as Record | undefined; + if (!metadata || metadata.schema_version !== 3) { + throw new Error("Workspace migration requires schema version 3"); + } + metadata.schema_version = 4; + delete value.semantic_index; + delete value.llm_policy; + return serializeWorkspaceYaml(validateWorkspaceDescriptor(value)); +} + export function validateWorkspaceDescriptor(workspace: unknown): WorkspaceDescriptor { return WorkspaceDescriptorSchema.parse(workspace) as WorkspaceDescriptor; } @@ -439,11 +401,11 @@ export function isCanonicalWorkspace(workspace: unknown): workspace is Canonical return WorkspaceDescriptorSchema.safeParse(workspace).success; } -export function isOperationalWorkspace(workspace: unknown): workspace is WorkspaceV3 { +export function isOperationalWorkspace(workspace: unknown): workspace is WorkspaceV4 { return WorkspaceDescriptorSchema.safeParse(workspace).success; } -export function validateOperationalWorkspace(workspace: unknown): WorkspaceV3 { +export function validateOperationalWorkspace(workspace: unknown): WorkspaceV4 { return validateWorkspaceDescriptor(workspace); } diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts index dd40c1fd..d5405246 100644 --- a/backend/src/workspaces/types.ts +++ b/backend/src/workspaces/types.ts @@ -19,4 +19,4 @@ export type WorkspaceErrorCode = | "workspace_stale" | "git_unavailable" | "git_auth_failed" | "git_non_fast_forward" | "connector_unavailable" | "semantic_index_incompatible"; -export type { WorkspaceV3 } from "./schema.js"; +export type { WorkspaceV4 } from "./schema.js"; diff --git a/backend/test/auth-pi-management-local.test.ts b/backend/test/auth-pi-management-local.test.ts index 606513c6..6861a989 100644 --- a/backend/test/auth-pi-management-local.test.ts +++ b/backend/test/auth-pi-management-local.test.ts @@ -5,14 +5,12 @@ import { createLocalAuthFixture } from "./auth-test-fixtures.js"; function fakeService(): PiManagementService { return { status: vi.fn(async () => ({ ready: true })), - options: vi.fn(async () => ({ providers: [], models: [], reasoning: [], checkedAt: "2026-08-17T00:00:00.000Z" })), - configure: vi.fn(async (value) => ({ ...value, updatedAt: "2026-08-17T00:00:00.000Z" })), test: vi.fn(async () => ({ ready: true, checkedAt: "2026-08-17T00:00:00.000Z" })), logs: vi.fn(async () => ({ lines: [] })), }; } -test("a local HTTPS cookie session authorizes Pi writes through an untrusted internal HTTP hop", async () => { +test("a local HTTPS cookie session authorizes the Pi smoke check through an untrusted internal HTTP hop", async () => { const service = fakeService(); const fixture = await createLocalAuthFixture( { piManagement: service }, @@ -25,31 +23,21 @@ test("a local HTTPS cookie session authorizes Pi writes through an untrusted int expect(fixture.publicUrl).toBe("HTTPS://thothii.example.test"); const proxyHeaders = fixture.sessionHeaders({ host: "127.0.0.1:8080" }); - const configured = await fixture.app.inject({ - method: "PUT", - url: "/pi-management/config", - headers: proxyHeaders, - payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, - }); const smoke = await fixture.app.inject({ method: "POST", url: "/pi-management/test", headers: proxyHeaders, }); - expect(configured.statusCode).toBe(200); expect(smoke.statusCode).toBe(200); - expect(service.configure).toHaveBeenCalledTimes(1); expect(service.test).toHaveBeenCalledTimes(1); fixture.resetDownstreamHits(); - vi.mocked(service.configure).mockClear(); vi.mocked(service.test).mockClear(); const wrongOrigin = await fixture.app.inject({ - method: "PUT", - url: "/pi-management/config", + method: "POST", + url: "/pi-management/test", headers: fixture.sessionHeaders({ host: "127.0.0.1:8080", origin: "https://evil.example" }), - payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, }); const wrongCsrf = await fixture.app.inject({ method: "POST", @@ -62,7 +50,6 @@ test("a local HTTPS cookie session authorizes Pi writes through an untrusted int expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" }); } expect(fixture.downstreamHits()).toBe(0); - expect(service.configure).not.toHaveBeenCalled(); expect(service.test).not.toHaveBeenCalled(); } finally { await fixture.close(); diff --git a/backend/test/catalog-databases-routes.test.ts b/backend/test/catalog-databases-routes.test.ts index f40f0c34..4648b002 100644 --- a/backend/test/catalog-databases-routes.test.ts +++ b/backend/test/catalog-databases-routes.test.ts @@ -19,16 +19,11 @@ afterEach(() => { }); const workspace: WorkspaceDescriptor = { - workspace: { schema_version: 3, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, + workspace: { schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct", "rest_api"], }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, diagnostics: { dwh_rest: { method: "GET", path: "/health", auth: "bearer", response: { database: "database", schema: "schema" } } }, }; const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" }; diff --git a/backend/test/catalog-description-generation-routes.test.ts b/backend/test/catalog-description-generation-routes.test.ts index 43008e25..6841671a 100644 --- a/backend/test/catalog-description-generation-routes.test.ts +++ b/backend/test/catalog-description-generation-routes.test.ts @@ -24,7 +24,7 @@ import type { WorkspaceDescriptor } from "../src/workspaces/schema.js"; const workspace: WorkspaceDescriptor = { workspace: { - schema_version: 3, + schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it", @@ -36,11 +36,6 @@ const workspace: WorkspaceDescriptor = { port: 5432, supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }; const revision: WorkspaceRevision = { id: "psd-clinical", @@ -49,7 +44,7 @@ const revision: WorkspaceRevision = { snapshotPath: "/tmp/psd.yaml", }; const configuredModel: ResolvedMetadataGenerationModel = { - id: "openai-mini", + id: "openai/gpt-4.1-mini", provider: "openai", model: "gpt-4.1-mini", apiKeyEnv: "OPENAI_API_KEY", @@ -705,7 +700,7 @@ test("generates one selected Catalog Column from a single JSON code fence", asyn expect(completionRequest.messages[0]?.content).toContain('{"results":['); expect(completionRequest.messages[1]?.content).toContain(`"targetId":"${column.id}"`); expect(completionRequest.messages[1]?.content).not.toMatch(/source rows|samples|example values/i); - expect(start.body).not.toMatch(/test-provider-secret|gpt-4\.1|openai\/gpt|Catalog metadata/); + expect(start.body).not.toMatch(/test-provider-secret|Catalog metadata/); resolveCompletion(`\`\`\`json\n${JSON.stringify({ results: [{ @@ -2909,7 +2904,7 @@ test("validates selected targets and resolves every requested target before laun const unknownModel = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, - payload: { modelId: "unknown-model", scope: "selected_columns", targetIds: [column.id] }, + payload: { modelId: "openai/unknown-model", scope: "selected_columns", targetIds: [column.id] }, }); expect(unknownModel.statusCode).toBe(409); expect(unknownModel.json().code).toBe("metadata_generation_model_unavailable"); diff --git a/backend/test/catalog-description-generation.integration.test.ts b/backend/test/catalog-description-generation.integration.test.ts index b3ed2ecd..15544f73 100644 --- a/backend/test/catalog-description-generation.integration.test.ts +++ b/backend/test/catalog-description-generation.integration.test.ts @@ -14,6 +14,7 @@ import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_des import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js"; import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js"; import { up as upAiTokenUsage } from "../src/catalog/migrations/009_ai_token_usage.js"; +import { up as upCanonicalModelIds } from "../src/catalog/migrations/010_canonical_model_ids.js"; import { KyselyCatalogRepository, type CatalogDatabase } from "../src/catalog/repository.js"; import { loadConfig } from "../src/config.js"; import type { WorkspaceRegistry } from "../src/workspaces/registry.js"; @@ -50,6 +51,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a await upDescriptionGeneration(db); await upSensitiveSuggestionRuns(db); await upAiTokenUsage(db); + await upCanonicalModelIds(db); const repository = new KyselyCatalogRepository(db); const database = await repository.create({ workspaceId: "psd-clinical", @@ -158,9 +160,9 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a }), }; const models: MetadataGenerationModels = { - catalog: () => ({ models: [{ id: "openai-mini", label: "OpenAI Mini" }], default: "openai-mini" }), + catalog: () => ({ models: [{ id: "openai/gpt-4.1-mini", label: "OpenAI Mini" }], default: "openai/gpt-4.1-mini" }), resolve: () => ({ - id: "openai-mini", + id: "openai/gpt-4.1-mini", provider: "openai", model: "gpt-4.1-mini", apiKeyEnv: "OPENAI_API_KEY", @@ -205,12 +207,12 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, payload: { - modelId: "openai-mini", + modelId: "openai/gpt-4.1-mini", scope: "selected_columns", targetIds: [status.id, birthDate.id], }, }); - expect(successfulStart.statusCode).toBe(202); + expect(successfulStart.statusCode, successfulStart.body).toBe(202); expect(await terminalRun(app, successfulStart.json().id)).toMatchObject({ status: "completed", total: 2, @@ -233,7 +235,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a const tableStart = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, - payload: { modelId: "openai-mini", scope: "selected_tables", targetIds: [table.id] }, + payload: { modelId: "openai/gpt-4.1-mini", scope: "selected_tables", targetIds: [table.id] }, }); expect(tableStart.statusCode).toBe(202); expect(await terminalRun(app, tableStart.json().id)).toMatchObject({ @@ -253,7 +255,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a const failedStart = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, - payload: { modelId: "openai-mini", scope: "selected_columns", targetIds: [status.id] }, + payload: { modelId: "openai/gpt-4.1-mini", scope: "selected_columns", targetIds: [status.id] }, }); expect(failedStart.statusCode).toBe(202); const failedRun = await terminalRun(app, failedStart.json().id); @@ -283,7 +285,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a const allStart = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, - payload: { modelId: "openai-mini", scope: "all" }, + payload: { modelId: "openai/gpt-4.1-mini", scope: "all" }, }); expect(allStart.statusCode).toBe(202); const allRun = await terminalRun(app, allStart.json().id); @@ -327,7 +329,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a const missingStart = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/description-generation-runs`, - payload: { modelId: "openai-mini", scope: "missing" }, + payload: { modelId: "openai/gpt-4.1-mini", scope: "missing" }, }); expect(missingStart.statusCode).toBe(202); expect(await terminalRun(app, missingStart.json().id)).toMatchObject({ diff --git a/backend/test/catalog-repository.integration.test.ts b/backend/test/catalog-repository.integration.test.ts index 2cc9d3cb..533b26e6 100644 --- a/backend/test/catalog-repository.integration.test.ts +++ b/backend/test/catalog-repository.integration.test.ts @@ -14,6 +14,7 @@ import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensiti import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js"; import { up as upLogicalRelationships } from "../src/catalog/migrations/008_catalog_logical_relationships.js"; import { up as upAiTokenUsage } from "../src/catalog/migrations/009_ai_token_usage.js"; +import { up as upCanonicalModelIds } from "../src/catalog/migrations/010_canonical_model_ids.js"; const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; @@ -32,6 +33,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo await upDescriptionGeneration(db); await upSensitiveSuggestionRuns(db); await upAiTokenUsage(db); + await upCanonicalModelIds(db); await sql`CREATE ROLE thothii_catalog_runtime`.execute(db); await upRuntimeSequencePrivileges(db); const sequencePrivilege = await sql<{ allowed: boolean }>` @@ -391,6 +393,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se await upDescriptionGeneration(db); await upSensitiveSuggestionRuns(db); await upAiTokenUsage(db); + await upCanonicalModelIds(db); const repository = new KyselyCatalogRepository(db); const firstDatabase = await repository.create({ workspaceId: "generation-one", @@ -428,14 +431,14 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se const run = await repository.createDescriptionGenerationRun( firstDatabase.id, "selected_columns", - "openai-mini", + "openai/gpt-4.1-mini", "it", 1, ); expect(run).toMatchObject({ databaseId: firstDatabase.id, scope: "selected_columns", - modelId: "openai-mini", + modelId: "openai/gpt-4.1-mini", language: "it", status: "queued", total: 1, @@ -450,7 +453,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se await expect(repository.createDescriptionGenerationRun( secondDatabase.id, "selected_columns", - "openai-mini", + "openai/gpt-4.1-mini", "en", 1, )).rejects.toThrow("A description generation run is already active"); @@ -461,7 +464,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se await expect(repository.createDescriptionGenerationRun( secondDatabase.id, "selected_columns", - "openai-mini", + "openai/gpt-4.1-mini", "en", 1, )).rejects.toThrow("A description generation run is already active"); @@ -505,7 +508,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se const next = await repository.createDescriptionGenerationRun( secondDatabase.id, "missing", - "openai-mini", + "openai/gpt-4.1-mini", "en", 1, ); @@ -533,7 +536,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se const allRun = await repository.createDescriptionGenerationRun( firstDatabase.id, "all", - "openai-mini", + "openai/gpt-4.1-mini", "it", 2, ); @@ -562,7 +565,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se const suggestionRun = await repository.createSensitiveDataSuggestionRun( firstDatabase.id, "selected_columns", - "openai-mini", + "openai/gpt-4.1-mini", ); expect(suggestionRun).toMatchObject({ databaseId: firstDatabase.id, @@ -604,7 +607,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se const interruptedSuggestionRun = await repository.createSensitiveDataSuggestionRun( secondDatabase.id, "all", - "openai-mini", + "openai/gpt-4.1-mini", ); expect(await repository.interruptActiveSensitiveDataSuggestionRuns( "Sensitive-field suggestion generation was interrupted by backend restart.", diff --git a/backend/test/catalog-schema-routes.test.ts b/backend/test/catalog-schema-routes.test.ts index 862c5dff..66cc9069 100644 --- a/backend/test/catalog-schema-routes.test.ts +++ b/backend/test/catalog-schema-routes.test.ts @@ -16,13 +16,8 @@ const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const workspace: WorkspaceDescriptor = { - workspace: { schema_version: 3, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, + workspace: { schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct"] }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }; const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" }; diff --git a/backend/test/catalog-tables-routes.test.ts b/backend/test/catalog-tables-routes.test.ts index 3bac80ff..7ac26acd 100644 --- a/backend/test/catalog-tables-routes.test.ts +++ b/backend/test/catalog-tables-routes.test.ts @@ -13,16 +13,11 @@ const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const workspace: WorkspaceDescriptor = { - workspace: { schema_version: 3, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, + workspace: { schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct", "rest_api"], }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, diagnostics: { dwh_rest: { method: "GET", path: "/health", auth: "bearer", response: { database: "database", schema: "schema" } } }, }; const revision: WorkspaceRevision = { diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index bad7b5b3..0578a36f 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -71,6 +71,7 @@ test("loadConfig keeps local development defaults", () => { workspaceSecretRuntimeRoot: "/tmp/thothii-workspace-secrets", internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", + internalEmbeddingId: "ollama/qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, authMode: "none", @@ -198,6 +199,22 @@ test("loadConfig accepts only the allowed internal semantic runtime hosts", () = .toThrow(/internal.*embedding|invalid/i); }); +test("loadConfig derives the embedding runtime model from its canonical catalog identity", () => { + expect(loadConfig({ + THT_INTERNAL_EMBEDDING_ID: "ollama/nomic-embed-text", + THT_INTERNAL_EMBEDDING_MODEL: "nomic-embed-text", + })).toMatchObject({ + internalEmbeddingId: "ollama/nomic-embed-text", + internalEmbeddingModel: "nomic-embed-text", + }); + expect(() => loadConfig({ + THT_INTERNAL_EMBEDDING_ID: "ollama/nomic-embed-text", + THT_INTERNAL_EMBEDDING_MODEL: "different-model", + })).toThrow("does not match its canonical identity"); + expect(() => loadConfig({ THT_INTERNAL_EMBEDDING_ID: "not-canonical" })) + .toThrow("embedding identity configuration is invalid"); +}); + test("loadConfig enables the legacy workspace request only through explicit local mode", () => { expect(loadConfig({ THT_LEGACY_WORKSPACE_MODE: "local" }).legacyWorkspaceMode).toBe(true); diff --git a/backend/test/effective-config.test.ts b/backend/test/effective-config.test.ts index ea60a30b..352e3fae 100644 --- a/backend/test/effective-config.test.ts +++ b/backend/test/effective-config.test.ts @@ -11,6 +11,7 @@ import { const semanticRuntime = { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", + internalEmbeddingId: "ollama/qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, }; @@ -102,11 +103,11 @@ function restRendered(): Record { } const directCanonical = - `{"schemaVersion":1,"dwh":{` + + `{"schemaVersion":2,"dwh":{` + `"engine":"postgres","database":"postgres","schema":"datawarehouse",` + `"transport":"postgres_direct","host":"dwh.internal","port":5432,"user":"thoth_reader"},` + `"vector":{"collection":"psd-clinical","dimensions":1024,"distance":"cosine"},` + - `"embedding":{"model":"qwen3-embedding:0.6b","dimensions":1024},` + + `"embedding":{"id":"ollama/qwen3-embedding:0.6b","model":"qwen3-embedding:0.6b","dimensions":1024},` + `"roots":{"artifacts":"/data/sessions/psd-clinical/artifacts",` + `"indexes":"/data/sessions/psd-clinical/indexes"}}`; @@ -192,6 +193,9 @@ test("DWH-affecting changes alter the effective config identity", () => { const changedCollection = { ...base, resources: { ...base.resources, vector: { ...(base.resources as Record).vector, collection: "other" } } }; expect(effectiveConfigIdentity("psd-clinical", changedCollection)).not.toBe(identityBefore); + const changedEmbeddingIdentity = { ...base, resources: { ...base.resources, embeddings: { ...(base.resources as Record).embeddings, model: "other-embedding" } } }; + expect(effectiveConfigIdentity("psd-clinical", changedEmbeddingIdentity)).not.toBe(identityBefore); + const changedTransport = restRendered(); expect(effectiveConfigIdentity("psd-clinical", changedTransport)).not.toBe(identityBefore); }); diff --git a/backend/test/metadata-generation-models.test.ts b/backend/test/metadata-generation-models.test.ts index ef6e4bcd..1e67cef0 100644 --- a/backend/test/metadata-generation-models.test.ts +++ b/backend/test/metadata-generation-models.test.ts @@ -1,15 +1,12 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, expect, test, vi } from "vitest"; -import { buildApp } from "../src/app.js"; -import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js"; +import { afterEach, expect, test } from "vitest"; import { loadMetadataGenerationModels, MetadataGenerationModelUnavailableError, } from "../src/catalog/metadata-generation-models.js"; -import { loadConfig } from "../src/config.js"; -import type { WorkspaceRegistry } from "../src/workspaces/registry.js"; +import { loadRuntimeModelCatalog, splitCanonicalModelId } from "../src/models/runtime-model-catalog.js"; const roots: string[] = []; @@ -17,260 +14,101 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function metadataConfiguration( - metadataGeneration: string, - secrets = "OPENAI_API_KEY=raw-provider-secret\n", -) { - const root = mkdtempSync(join(tmpdir(), "thothii-metadata-models-")); +function runtimeCatalog(overrides: Record = {}, secrets = "OPENAI_API_KEY=raw-provider-secret\n") { + const root = mkdtempSync(join(tmpdir(), "thothii-runtime-models-")); roots.push(root); - const installationFile = join(root, "thothii-installation.yaml"); + const catalogFile = join(root, "catalog.json"); const secretsFile = join(root, "thothii.secrets"); - writeFileSync(installationFile, metadataGeneration, { mode: 0o600 }); - writeFileSync(secretsFile, secrets, { mode: 0o600 }); - chmodSync(installationFile, 0o600); - chmodSync(secretsFile, 0o600); - return { installationFile, secretsFile }; -} - -function appFor(installationFile: string, secretsFile: string) { - const config = loadConfig({ - NODE_ENV: "test", - THT_HARNESS_DIR: "/missing", - THT_INSTALLATION_CONFIG_FILE: installationFile, - THT_SECRETS_FILE: secretsFile, - PI_PROVIDER: "unrelated-pi-provider", - PI_MODEL: "unrelated-pi-model", - }); - return buildApp(config, { - thtRunner: {} as never, - workspaceRegistry: { list: vi.fn(async () => []) } as unknown as WorkspaceRegistry, - workspaceDiagnoser: vi.fn(), - catalogRepository: new MemoryCatalogRepository(), - }); -} - -test("exposes only safe metadata-generation choices and their configured default", async () => { - const { installationFile, secretsFile } = metadataConfiguration(`metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: - provider: openai - model: gpt-4.1-mini - endpoint: - baseUrl: https://api.openai.example/v1 - apiVersion: "2026-08-01" - apiKeyEnv: OPENAI_API_KEY -`); - const app = appFor(installationFile, secretsFile); - - const response = await app.inject({ method: "GET", url: "/catalog/metadata-generation/models" }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - models: [{ id: "openai-mini", label: "OpenAI Mini" }], - default: "openai-mini", - }); - expect(response.body).not.toMatch(/openai\/gpt|gpt-4\.1|api\.openai|OPENAI_API_KEY|raw-provider-secret/); - await app.close(); -}); - -test("rejects an unprotected installation descriptor", () => { - const { installationFile, secretsFile } = metadataConfiguration(`metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`); - chmodSync(installationFile, 0o644); - - expect(() => loadMetadataGenerationModels({ installationFile, secretsFile })) - .toThrow("metadata-generation installation is unavailable"); -}); - -test("returns an empty safe catalog when no metadata-generation model is configured", async () => { - const { installationFile, secretsFile } = metadataConfiguration("profile: local\n"); - const app = appFor(installationFile, secretsFile); - - const response = await app.inject({ method: "GET", url: "/catalog/metadata-generation/models" }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ models: [], default: null }); - await app.close(); -}); - -test("resolves only a configured selection for the later generation boundary", () => { - const { installationFile, secretsFile } = metadataConfiguration(`metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: - provider: openai - model: gpt-4.1-mini - endpoint: {baseUrl: https://api.openai.example/v1, apiVersion: "2026-08-01"} - apiKeyEnv: OPENAI_API_KEY -`); - const models = loadMetadataGenerationModels({ installationFile, secretsFile }); - - expect(models.resolve("openai-mini")).toEqual({ - id: "openai-mini", - provider: "openai", - model: "gpt-4.1-mini", - endpoint: { baseUrl: "https://api.openai.example/v1", apiVersion: "2026-08-01" }, - apiKeyEnv: "OPENAI_API_KEY", - apiKey: "raw-provider-secret", - }); - expect(() => models.resolve("unknown-model")).toThrow(MetadataGenerationModelUnavailableError); -}); - -test("loads DeepSeek models, GLM, and an explicit keyless Qwen endpoint from installation setup", () => { - const { installationFile, secretsFile } = metadataConfiguration(`metadataGeneration: - default: glm-53 - models: - - id: deepseek-v4-pro - label: DeepSeek V4 Pro - litellm: {provider: deepseek, model: deepseek-v4-pro} - apiKeyEnv: DEEPSEEK_API_KEY - - id: deepseek-v4-flash - label: DeepSeek V4 Flash - litellm: {provider: deepseek, model: deepseek-v4-flash} - apiKeyEnv: DEEPSEEK_API_KEY - - id: glm-53 - label: GLM 5.3 - litellm: - provider: openai - model: glm-5.3 - endpoint: {baseUrl: https://api.z.ai/api/coding/paas/v4} - apiKeyEnv: ZAI_API_KEY - - id: qwen-36 - label: Qwen 3.6 - litellm: - provider: openai - model: qwen3.6-35b-a3b - disableThinking: true - endpoint: {baseUrl: https://models.internal.example/v1} -`, "DEEPSEEK_API_KEY=deepseek-secret\nZAI_API_KEY=zai-secret\n"); - - const models = loadMetadataGenerationModels({ installationFile, secretsFile }); - - expect(models.catalog()).toEqual({ + const catalog = { + schemaVersion: 1, + defaultSession: "zai/glm-5.3", + defaultMetadataGeneration: "zai/glm-5.3", + embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, models: [ - { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" }, - { id: "glm-53", label: "GLM 5.3" }, - { id: "qwen-36", label: "Qwen 3.6" }, + { + id: "zai/glm-5.3", provider: "zai", model: "glm-5.3", label: "GLM 5.3", + upstreamModel: "glm-5.3", endpoint: { baseUrl: "https://api.z.ai/v1" }, + authentication: { mode: "secret_env", apiKeyEnv: "OPENAI_API_KEY" }, + sessionAdapter: { mode: "openai_compatible" }, + metadataAdapter: { litellmProvider: "openai" }, + session: { reasoning: true, contextWindow: 200000, maxTokens: 131072 }, + metadataGeneration: { disableThinking: false }, + }, + { + id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", upstreamModel: "deepseek-v4-pro", + authentication: { mode: "pi_auth" }, sessionAdapter: { mode: "pi_builtin" }, + session: { reasoning: false }, + }, ], - default: "glm-53", + ...overrides, + }; + writeFileSync(catalogFile, JSON.stringify(catalog), { mode: 0o600 }); + writeFileSync(secretsFile, secrets, { mode: 0o600 }); + chmodSync(catalogFile, 0o600); + chmodSync(secretsFile, 0o600); + return { catalogFile, secretsFile }; +} + +test("loads session default and safe metadata choices from the normalized runtime catalog", () => { + const { catalogFile, secretsFile } = runtimeCatalog(); + const runtime = loadRuntimeModelCatalog(catalogFile); + const metadata = loadMetadataGenerationModels({ catalogFile, secretsFile }); + + expect(runtime.defaultSession).toBe("zai/glm-5.3"); + expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(true); + expect(metadata.catalog()).toEqual({ + models: [{ id: "zai/glm-5.3", label: "GLM 5.3" }], + default: "zai/glm-5.3", }); - expect(models.resolve("deepseek-v4-pro")).toMatchObject({ - apiKeyEnv: "DEEPSEEK_API_KEY", - apiKey: "deepseek-secret", - }); - expect(models.resolve("qwen-36")).toEqual({ - id: "qwen-36", - provider: "openai", - model: "qwen3.6-35b-a3b", - disableThinking: true, - endpoint: { baseUrl: "https://models.internal.example/v1" }, + expect(metadata.resolve("zai/glm-5.3")).toEqual({ + id: "zai/glm-5.3", provider: "openai", model: "glm-5.3", + endpoint: { baseUrl: "https://api.z.ai/v1" }, + apiKeyEnv: "OPENAI_API_KEY", apiKey: "raw-provider-secret", }); + expect(() => metadata.resolve("zai/missing")).toThrow(MetadataGenerationModelUnavailableError); }); -test("loads an explicit keyless endpoint without a secret bundle", () => { - const { installationFile } = metadataConfiguration(`metadataGeneration: - default: qwen-36 - models: - - id: qwen-36 - label: Qwen 3.6 - litellm: - provider: openai - model: qwen3.6-35b-a3b - disableThinking: true - endpoint: {baseUrl: https://models.internal.example/v1} -`); +test("returns empty catalogs when no runtime projection is configured", () => { + expect(loadRuntimeModelCatalog().defaultSession).toBeNull(); + expect(loadMetadataGenerationModels({}).catalog()).toEqual({ models: [], default: null }); +}); - expect(loadMetadataGenerationModels({ installationFile }).resolve("qwen-36")).toEqual({ - id: "qwen-36", - provider: "openai", - model: "qwen3.6-35b-a3b", - disableThinking: true, - endpoint: { baseUrl: "https://models.internal.example/v1" }, +test("rejects a drifted default and an unprotected projection", () => { + const drifted = runtimeCatalog({ defaultSession: "zai/missing" }); + expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("session default is invalid"); + + const unprotected = runtimeCatalog(); + chmodSync(unprotected.catalogFile, 0o666); + expect(() => loadRuntimeModelCatalog(unprotected.catalogFile)).toThrow("runtime model catalog is unavailable"); +}); + +test("rejects authentication semantics that cannot come from the installation catalog", () => { + const invalid = runtimeCatalog({ + defaultMetadataGeneration: undefined, + models: [{ + id: "zai/glm-5.3", + provider: "zai", + model: "glm-5.3", + label: "GLM 5.3", + upstreamModel: "glm-5.3", + authentication: { mode: "secret_env" }, + sessionAdapter: { mode: "pi_builtin" }, + session: { reasoning: true }, + }], }); + expect(() => loadRuntimeModelCatalog(invalid.catalogFile)).toThrow("runtime model catalog is invalid"); }); -test.each([ - ["invalid YAML", "metadataGeneration: [\n", "OPENAI_API_KEY=secret\n", /invalid YAML/], - ["duplicate ids", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} - - {id: openai-mini, label: Two, litellm: {provider: openai, model: gpt-4.1}, apiKeyEnv: OPENAI_API_KEY} -`, "OPENAI_API_KEY=secret\n", /model id "openai-mini" is duplicated/], - ["missing default", `metadataGeneration: - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`, "OPENAI_API_KEY=secret\n", /default is required/], - ["unknown default", `metadataGeneration: - default: absent - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`, "OPENAI_API_KEY=secret\n", /default "absent" is not configured/], - ["malformed settings", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: "open ai", model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`, "OPENAI_API_KEY=secret\n", /configuration is invalid/], - ["malformed endpoint", `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: One - litellm: {provider: openai, model: gpt-4.1-mini, endpoint: {baseUrl: not-a-url}} - apiKeyEnv: OPENAI_API_KEY -`, "OPENAI_API_KEY=secret\n", /configuration is invalid/], - ["keyless hosted model without endpoint", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}} -`, "", /configuration is invalid/], - ["disable thinking without endpoint", `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: One - litellm: {provider: openai, model: gpt-4.1-mini, disableThinking: true} - apiKeyEnv: OPENAI_API_KEY -`, "OPENAI_API_KEY=secret\n", /configuration is invalid/], - ["unallowed secret reference", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: THT_DWH_API_KEY} -`, "THT_DWH_API_KEY=secret\n", /configuration is invalid/], - ["missing referenced secret", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`, "THT_DWH_API_KEY=secret\n", /secret "OPENAI_API_KEY" is missing/], - ["unusable referenced secret", `metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`, "OPENAI_API_KEY=secret with whitespace\n", /secret "OPENAI_API_KEY" is unusable/], -] as const)("rejects %s metadata-generation configuration", (_name, yaml, secrets, expected) => { - const { installationFile, secretsFile } = metadataConfiguration(yaml, secrets); - expect(() => loadMetadataGenerationModels({ installationFile, secretsFile })).toThrow(expected); +test("fails closed for missing or unusable provider secrets", () => { + const missing = runtimeCatalog({}, "THT_DWH_API_KEY=other\n"); + expect(() => loadMetadataGenerationModels(missing)).toThrow('secret "OPENAI_API_KEY" is missing'); + + const unusable = runtimeCatalog({}, "OPENAI_API_KEY=contains whitespace\n"); + expect(() => loadMetadataGenerationModels(unusable)).toThrow('secret "OPENAI_API_KEY" is unusable'); }); -test("rejects a missing secret-bundle declaration for configured models", () => { - const { installationFile } = metadataConfiguration(`metadataGeneration: - default: openai-mini - models: - - {id: openai-mini, label: One, litellm: {provider: openai, model: gpt-4.1-mini}, apiKeyEnv: OPENAI_API_KEY} -`); - - expect(() => loadMetadataGenerationModels({ installationFile })) - .toThrow("metadata-generation keyed models require THT_SECRETS_FILE"); +test("splits canonical session identities without provider aliases", () => { + expect(splitCanonicalModelId("zai/glm-5.3")).toEqual({ provider: "zai", model: "glm-5.3" }); + expect(() => splitCanonicalModelId("glm-5.3")).toThrow("model identity is invalid"); }); diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts index f0d79e8d..f03c336e 100644 --- a/backend/test/pi-management.test.ts +++ b/backend/test/pi-management.test.ts @@ -1,13 +1,13 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; import { loadConfig } from "../src/config.js"; import { - PiManagementError, createPiManagement, type PiExecFile, } from "../src/pi/management.js"; +import type { RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js"; function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) { return loadConfig({ @@ -18,10 +18,14 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage }); } -const supportedModels = [ - { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, - { provider: "deepseek", id: "deepseek-v4", name: "DeepSeek V4", reasoning: true }, -]; +const modelCatalog: RuntimeModelCatalog = { + defaultSession: "zai/glm-5.2", + defaultMetadataGeneration: null, + embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, + sessionModels: () => [], + metadataModels: () => [], + hasSession: (id) => id === "zai/glm-5.2", +}; function successfulExec(calls: Array<{ command: string; args: string[]; timeout: number }>): PiExecFile { return async (command, args, options) => { @@ -36,7 +40,7 @@ test("status parses only a Pi version from a fixed execFile argument array", asy const calls: Array<{ command: string; args: string[]; timeout: number }> = []; const service = createPiManagement(configFor(), { execute: successfulExec(calls), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), credentialStatus: () => "missing", now: () => new Date("2026-08-05T10:00:00.000Z"), @@ -63,7 +67,7 @@ test.each(["present", "missing"] as const)( const checkedProviders: Array = []; const service = createPiManagement(configFor(), { execute: successfulExec([]), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), credentialStatus: (provider) => { checkedProviders.push(provider); @@ -85,100 +89,6 @@ test.each(["present", "missing"] as const)( }, ); -// Catches an options response that leaks provider metadata or lets callers choose model IDs that -// Pi did not explicitly enable for this installation. -test("options expose only closed provider, model, and reasoning choices", async () => { - const service = createPiManagement(configFor(), { - execute: successfulExec([]), - listModels: async () => supportedModels, - now: () => new Date("2026-08-05T10:00:00.000Z"), - }); - - await expect(service.options()).resolves.toEqual({ - providers: ["zai", "deepseek"], - models: [ - { provider: "zai", id: "glm-5.2" }, - { provider: "deepseek", id: "deepseek-v4" }, - ], - reasoning: ["low", "medium", "high"], - checkedAt: "2026-08-05T10:00:00.000Z", - }); -}); - -// Catches raw managed models.json validation details being collapsed into an ambiguous model-list -// failure or escaping through the Pi Management options API. -test("options report invalid managed model configuration with a stable sanitized error", async () => { - const service = createPiManagement(configFor(), { - execute: successfulExec([]), - listModels: async () => { - throw Object.assign( - new Error("!sensitive-command /private/models.json raw-secret"), - { code: "PI_MANAGED_CONFIG_INVALID" }, - ); - }, - }); - - let caught: unknown; - try { - await service.options(); - } catch (error) { - caught = error; - } - expect(caught).toMatchObject({ - code: "pi_management_unavailable", - message: "Pi provider/model configuration is invalid", - }); - expect(String(caught)).not.toMatch(/sensitive|private|models\.json|secret/i); -}); - -// Catches configuration writes that accept whitespace, unknown choices, or extra free-form fields -// before reaching the durable installation settings file. -test("config rejects invalid free-form values before writing settings", async () => { - const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-invalid-")); - try { - let writes = 0; - const service = createPiManagement(configFor(join(directory, "settings.json")), { - execute: successfulExec([]), - listModels: async () => supportedModels, - readSettings: () => ({}), - saveSettings: () => { writes += 1; return {}; }, - }); - - await expect(service.configure({ - provider: "zai ", model: "glm-5.2", reasoning: "medium", unexpected: "value", - } as any)).rejects.toMatchObject({ code: "pi_management_invalid_config" }); - expect(writes).toBe(0); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); - -// Catches a non-atomic implementation that can leave partial settings or temporary files after a -// normal installation-default update. -test("config validates closed choices and atomically persists non-secret defaults", async () => { - const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-write-")); - const settingsFile = join(directory, "settings.json"); - try { - const service = createPiManagement(configFor(settingsFile), { - execute: successfulExec([]), - listModels: async () => supportedModels, - now: () => new Date("2026-08-05T10:00:00.000Z"), - }); - - await expect(service.configure({ - provider: "zai", model: "glm-5.2", reasoning: "high", - })).resolves.toEqual({ - provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z", - }); - expect(JSON.parse(readFileSync(settingsFile, "utf8"))).toEqual({ - provider: "zai", model: "glm-5.2", thinking: "high", - }); - expect(readdirSync(directory)).toEqual(["settings.json"]); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); - // Catches a hung Pi smoke check that leaves an operator waiting indefinitely or returns raw child // diagnostics containing provider credentials. test("smoke uses the configured timeout and reports a sanitized timeout", async () => { @@ -188,7 +98,7 @@ test("smoke uses the configured timeout and reports a sanitized timeout", async calls.push({ command, args, timeout: options.timeout }); throw Object.assign(new Error("provider token=raw-provider-token"), { code: "ETIMEDOUT" }); }, - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), now: () => new Date("2026-08-05T10:00:00.000Z"), }); @@ -210,7 +120,7 @@ test("smoke exercises the configured provider and model", async () => { const providerChecks: unknown[] = []; const service = createPiManagement(configFor(), { execute: successfulExec([]), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), smokeProvider: async (request) => { providerChecks.push(request); }, now: () => new Date("2026-08-05T10:00:00.000Z"), @@ -230,7 +140,7 @@ test("smoke exercises the configured provider and model", async () => { test("smoke fails closed and sanitizes configured-provider authentication errors", async () => { const service = createPiManagement(configFor(), { execute: successfulExec([]), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), smokeProvider: async () => { throw new Error('401 {"token":"raw-expired-token","output":"raw-provider-output"}'); @@ -252,7 +162,7 @@ test("smoke fails closed and sanitizes configured-provider authentication errors test("smoke reports invalid managed provider configuration with a stable sanitized error", async () => { const service = createPiManagement(configFor(), { execute: successfulExec([]), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), smokeProvider: async () => { throw Object.assign( @@ -284,7 +194,7 @@ test("smoke applies one deadline across version and a hung provider turn", async () => resolve({ stdout: "pi 0.80.3\n", stderr: "" }), 500, )), - listModels: async () => supportedModels, + modelCatalog, readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), smokeProvider: async ({ timeoutMs }) => { providerTimeouts.push(timeoutMs); @@ -320,7 +230,7 @@ test("logs keep only the latest 200 redacted lines", async () => { source[201] = "THT_MODEL_API_KEY=raw-env-secret"; const service = createPiManagement(configFor(), { execute: successfulExec([]), - listModels: async () => supportedModels, + modelCatalog, readLogs: () => source.join("\n"), now: () => new Date("2026-08-05T10:00:00.000Z"), }); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 7a9d07a0..6782be16 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -8,6 +8,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { PiProcessManager } from "../src/pi/pi-process-manager.js"; +import type { RuntimeModelCatalog, RuntimeModel } from "../src/models/runtime-model-catalog.js"; import { loadConfig } from "../src/config.js"; import { PI_MANAGED_CONFIG_ERROR_MESSAGE, @@ -641,6 +642,53 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn } }); +test("session Pi spawn resolves the selected catalog credential from the secret bundle", () => { + const root = mkdtempSync(path.join(tmpdir(), "thothii-catalog-credential-")); + const agentDir = path.join(root, "agent"); + mkdirSync(agentDir, { mode: 0o700 }); + writeFileSync(path.join(agentDir, "auth.json"), "{}\n", { mode: 0o600 }); + writeFileSync(path.join(agentDir, "models.json"), '{"providers":{}}\n', { mode: 0o600 }); + const secret = path.join(root, "thothii.secrets"); + writeFileSync(secret, "ZAI_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 }); + const model: RuntimeModel = { + id: "openai/test-model", + provider: "openai", + model: "test-model", + label: "Test model", + upstreamModel: "test-model", + authentication: { mode: "secret_env", apiKeyEnv: "ZAI_API_KEY" }, + sessionAdapter: { mode: "pi_builtin" }, + session: { reasoning: false }, + }; + const modelCatalog: RuntimeModelCatalog = { + defaultSession: model.id, + defaultMetadataGeneration: null, + embedding: null, + sessionModels: () => [model], + metadataModels: () => [], + hasSession: (id) => id === model.id, + }; + const calls: any[][] = []; + const child = recordingChild(); + child.stderr.resume = () => {}; + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret }), { + modelCatalog, + authProviders: () => new Set(), + spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, + }); + try { + mgr.createFor("catalog-credential", { provider: "openai", model: "test-model" }); + expect(calls[0][2].env.ZAI_API_KEY).toBe("catalog-secret"); + expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY"); + expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY"); + } finally { + mgr.teardown("catalog-credential"); + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } +}); + test.each([["OpenAI", "openai"], ["gemini", "google"]])( "set_model uses canonical packaged provider ID for %s", async (provider, canonical) => { const secret = path.resolve(__dirname, `.canonical-key-${process.pid}-${provider}`); diff --git a/backend/test/pi-provider-smoke.test.ts b/backend/test/pi-provider-smoke.test.ts index b96499cc..3794fcda 100644 --- a/backend/test/pi-provider-smoke.test.ts +++ b/backend/test/pi-provider-smoke.test.ts @@ -1,9 +1,11 @@ import { EventEmitter } from "node:events"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { dirname } from "node:path"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { loadConfig } from "../src/config.js"; import { createPiProviderSmoke } from "../src/pi/provider-smoke.js"; +import type { RuntimeModel, RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js"; afterEach(() => vi.unstubAllEnvs()); @@ -44,6 +46,50 @@ function successfulProviderChild() { const MANAGED_CONFIG_ERROR = "Pi provider/model configuration is invalid"; +test("provider smoke resolves the selected catalog credential from the secret bundle", async () => { + const root = mkdtempSync(join(tmpdir(), "thothii-smoke-catalog-credential-")); + const secret = join(root, "thothii.secrets"); + writeFileSync(secret, "ZAI_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 }); + const model: RuntimeModel = { + id: "openai/test-model", + provider: "openai", + model: "test-model", + label: "Test model", + upstreamModel: "test-model", + authentication: { mode: "secret_env", apiKeyEnv: "ZAI_API_KEY" }, + sessionAdapter: { mode: "pi_builtin" }, + session: { reasoning: false }, + }; + const modelCatalog: RuntimeModelCatalog = { + defaultSession: model.id, + defaultMetadataGeneration: null, + embedding: null, + sessionModels: () => [model], + metadataModels: () => [], + hasSession: (id) => id === model.id, + }; + let spawnEnv: NodeJS.ProcessEnv | undefined; + const smoke = createPiProviderSmoke(loadConfig({ THT_SECRETS_FILE: secret }), { + modelCatalog, + authProviders: () => new Set(), + readModelsStore: () => undefined, + spawnFn: (_command, _args, options) => { + spawnEnv = options.env; + return successfulProviderChild(); + }, + }); + try { + await expect(smoke({ + provider: "openai", model: "test-model", reasoning: "medium", timeoutMs: 750, + })).resolves.toBeUndefined(); + expect(spawnEnv?.ZAI_API_KEY).toBe("catalog-secret"); + expect(spawnEnv).not.toHaveProperty("OPENAI_API_KEY"); + expect(spawnEnv).not.toHaveProperty("THT_MODEL_API_KEY"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + // Catches an isolated smoke agent that copies auth.json but drops the selected custom // provider/model from models.json, causing set_model to fail before the real request. test("provider smoke reaches the selected custom provider from an isolated models.json", async () => { diff --git a/backend/test/readiness-manager.test.ts b/backend/test/readiness-manager.test.ts index 5eb22fcc..cfa61f76 100644 --- a/backend/test/readiness-manager.test.ts +++ b/backend/test/readiness-manager.test.ts @@ -2,18 +2,11 @@ import { expect, test } from "vitest"; import { ReadinessManager } from "../src/runtime/readiness-manager.js"; const workspace = { - workspace: { schema_version: 3, id: "psd", name: "PSD", language: "it" }, + workspace: { schema_version: 4, id: "psd", name: "PSD", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "public", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { - provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, } as const; function deferred() { diff --git a/backend/test/registry-annotations.test.ts b/backend/test/registry-annotations.test.ts index 094d3152..3aed2451 100644 --- a/backend/test/registry-annotations.test.ts +++ b/backend/test/registry-annotations.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }); const validYaml = `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -24,18 +24,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `; async function git(cwd: string, args: string[]): Promise { diff --git a/backend/test/registry-evidence.test.ts b/backend/test/registry-evidence.test.ts index 66e17e19..ea9c7c1c 100644 --- a/backend/test/registry-evidence.test.ts +++ b/backend/test/registry-evidence.test.ts @@ -20,7 +20,7 @@ async function git(cwd: string, args: string[]): Promise { } const descriptor = `workspace: - schema_version: 3 + schema_version: 4 id: research name: Research language: en @@ -29,18 +29,6 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: research - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] evidence: source: type: filesystem diff --git a/backend/test/routes-pi-management.test.ts b/backend/test/routes-pi-management.test.ts index d1d98a9a..4b7299b1 100644 --- a/backend/test/routes-pi-management.test.ts +++ b/backend/test/routes-pi-management.test.ts @@ -14,11 +14,6 @@ function fakeService(): PiManagementService { config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, checkedAt: "2026-08-05T10:00:00.000Z", })), - options: vi.fn(async () => ({ - providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], - reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z", - })), - configure: vi.fn(async (value) => ({ ...value, updatedAt: "2026-08-05T10:00:00.000Z" })), test: vi.fn(async () => ({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" })), logs: vi.fn(async () => ({ lines: ["Pi smoke check succeeded"], checkedAt: "2026-08-05T10:00:00.000Z" })), }; @@ -106,24 +101,17 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () = }); // A local implicit administrator has pi.manage, but a browser origin still cannot borrow that -// authority to mutate local configuration or trigger provider work. +// authority to trigger provider work. test("loopback-only management rejects cross-origin writes for its local administrator", async () => { const service = fakeService(); const app = appWith(service); try { - const configured = await app.inject({ - method: "PUT", url: "/pi-management/config", - headers: { host: "127.0.0.1:8080", origin: "https://evil.example" }, - payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, - }); const smoke = await app.inject({ method: "POST", url: "/pi-management/test", headers: { host: "127.0.0.1:8080", origin: "https://evil.example" }, }); - expect(configured.statusCode).toBe(403); expect(smoke.statusCode).toBe(403); - expect(service.configure).not.toHaveBeenCalled(); expect(service.test).not.toHaveBeenCalled(); } finally { await app.close(); @@ -132,14 +120,13 @@ test("loopback-only management rejects cross-origin writes for its local adminis // Catches an origin guard that also blocks the same-origin Docker frontend or non-browser local // lifecycle clients that do not send Origin. -test("loopback-only management preserves same-origin frontend and origin-less local writes", async () => { +test("loopback-only management preserves same-origin and origin-less smoke checks", async () => { const service = fakeService(); const app = appWith(service); try { const sameOrigin = await app.inject({ - method: "PUT", url: "/pi-management/config", + method: "POST", url: "/pi-management/test", headers: { host: "127.0.0.1:8080", origin: "http://127.0.0.1:8080" }, - payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, }); const lifecycleClient = await app.inject({ method: "POST", url: "/pi-management/test" }); @@ -150,25 +137,20 @@ test("loopback-only management preserves same-origin frontend and origin-less lo } }); -// Catches route wiring that bypasses closed service validation or gives the browser a Docker/image -// lifecycle endpoint rather than only installation-default configuration and diagnostics. -test("trusted admins receive only configuration, smoke, options, and log endpoints", async () => { +// Catches a regression that reintroduces a browser-writable provider/model source. +test("trusted admins receive only status, smoke, and log endpoints", async () => { const service = fakeService(); const app = appWith(service, exposedServerEnv); try { - const options = await app.inject({ method: "GET", url: "/pi-management/options", headers: adminHeaders }); - const configured = await app.inject({ - method: "PUT", url: "/pi-management/config", headers: adminHeaders, - payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, - }); + const status = await app.inject({ method: "GET", url: "/pi-management/status", headers: adminHeaders }); const smoke = await app.inject({ method: "POST", url: "/pi-management/test", headers: adminHeaders }); const logs = await app.inject({ method: "GET", url: "/pi-management/logs", headers: adminHeaders }); - expect(options.statusCode).toBe(200); - expect(configured.statusCode).toBe(200); - expect(configured.json()).toMatchObject({ provider: "zai", model: "glm-5.2", reasoning: "high" }); + expect(status.statusCode).toBe(200); expect(smoke.statusCode).toBe(200); expect(logs.statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/pi-management/options", headers: adminHeaders })).statusCode).toBe(404); + expect((await app.inject({ method: "PUT", url: "/pi-management/config", headers: adminHeaders })).statusCode).toBe(404); expect(app.printRoutes()).not.toContain("update"); expect(app.printRoutes()).not.toContain("rollback"); } finally { diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 1e170f69..622d17d2 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -18,25 +18,25 @@ const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation. function operationalWorkspace(id = "default") { return { - workspace: { schema_version: 3, id, name: id, language: "en" }, + workspace: { schema_version: 4, id, name: id, language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "public", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { - engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine", - }, - embedding: { - provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, - }, - }, - llm_policy: { - allowed: ["zai/glm-5.2", "deepseek/deepseek-v4-pro", "local-qwen/qwen3.6-35b-a3b"], - }, } as const; } +function sessionCatalog(defaultSession = "zai/glm-5.2", available = [defaultSession]) { + return { + defaultSession, + defaultMetadataGeneration: null, + embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, + sessionModels: () => [], + metadataModels: () => [], + hasSession: (id: string) => available.includes(id), + } as any; +} + const defaultWorkspaceRegistry = { list: vi.fn(async () => [{ id: "default", commit: "e".repeat(40), blob: "f".repeat(40), @@ -495,8 +495,8 @@ test("creates a session from the active immutable workspace revision", async () workspaceRegistry: { read: vi.fn(async () => ({ workspace: { - workspace: { schema_version: 2, id: "psd-clinical", name: "PSD", language: "it" }, - dwh: {}, semantic_index: {}, llm_policy: { allowed: ["zai/glm-5.2"] }, + workspace: { schema_version: 4, id: "psd-clinical", name: "PSD", language: "it" }, + dwh: {}, }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), @@ -589,22 +589,11 @@ test("rejects an SSH-only workspace before persisting or starting a session", as workspaceRegistry: { acquireSessionRevision: vi.fn(async () => ({ workspace: { - workspace: { schema_version: 2, id: "ssh-workspace", name: "SSH", language: "en" }, + workspace: { schema_version: 4, id: "ssh-workspace", name: "SSH", language: "en" }, dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["ssh_tunnel"], }, - semantic_index: { - vector_store: { - engine: "pgvector", database: "postgres", schema: "vectors", - collection: "documents", dimensions: 768, distance: "cosine", - supported_transports: ["ssh_tunnel"], - }, - embedding: { - provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }, revision: { id: "ssh-workspace", commit: "a".repeat(40), blob: "b".repeat(40), @@ -632,7 +621,7 @@ test("hands a revision lease to retention only after the session manifest is dur const markPersisted = vi.fn(async () => {}); const abort = vi.fn(async () => {}); const acquireSessionRevision = vi.fn(async () => ({ - workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + workspace: operationalWorkspace("leased"), revision: { id: "leased", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/leased.yaml`, @@ -670,7 +659,7 @@ test("creates a session from the configured default workspace revision when work const sessionNew = vi.fn(async () => ({ id: "default-pinned" })); const registry = { read: vi.fn(async (id: string) => ({ - workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + workspace: operationalWorkspace(id), revision: { id, commit: "c".repeat(40), blob: "d".repeat(40), snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, @@ -758,7 +747,7 @@ test("session lifecycle locates a B session when installation default is A", asy listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], workspaceRegistry: { read: async (id: string) => ({ - workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, + workspace: operationalWorkspace(id), revision: { id, commit: "b".repeat(40), blob: "d".repeat(40), snapshotPath: bPath }, }), list: async () => [ @@ -793,7 +782,7 @@ test("session lifecycle locates a B session when installation default is A", asy expect(runtimeOptions).toEqual(["/runtime/1.yaml", "/runtime/2.yaml"]); }); -test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => { +test("POST /sessions uses the catalog default with workspace/thinking settings and starts", async () => { const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`); writeFileSync(modelKey, "test-model-key", { mode: 0o600 }); chmodSync(modelKey, 0o600); @@ -808,7 +797,8 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; }, sessionList: async () => [{ id: "s1" }], } as any, - getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }), + getSettings: () => ({ workspace: "w", thinking: "high" }), + runtimeModelCatalog: sessionCatalog(), listModels: async () => [ { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, ], @@ -2565,32 +2555,43 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`); }); -test("POST /sessions rejects an unavailable saved model before persisting a session", async () => { +test("POST /sessions falls back from a stale requested model to the catalog default", async () => { let created = 0; + let persisted: any; + const runtime = { bridge: { onClientEvent: () => {} } }; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { - sessionNew: async () => { created += 1; return { id: "must-not-exist" }; }, + sessionNew: async (options: any) => { created += 1; persisted = options; return { id: "fallback" }; }, + searchPack: async () => {}, } as any, readiness: { ensure: async () => ({ ok: true }) } as any, - getSettings: () => ({ - workspace: "psd", - provider: "deepseek", - model: "deepseek-v4-pro", - thinking: "medium", - }) as any, + getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any, + runtimeModelCatalog: sessionCatalog(), listModels: async () => [ { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, ], + mgr: { + teardownForPrincipal: () => [], + createFor: () => runtime, + get: () => runtime, + configure: async () => {}, + start: () => {}, + } as any, }); - const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + const res = await app.inject({ + method: "POST", + url: "/sessions", + payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" }, + }); - expect(res.statusCode).toBe(503); + expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ - error: "Selected model is unavailable. Check Pi authentication and model settings, then try again.", - code: "model_unavailable", + id: "fallback", + warning: "Configured model deepseek/deepseek-v4-pro is unavailable; using zai/glm-5.2.", }); - expect(created).toBe(0); + expect(persisted).toMatchObject({ provider: "zai", model: "glm-5.2" }); + expect(created).toBe(1); }); test("POST /sessions marks a persisted session failed when runtime construction throws", async () => { diff --git a/backend/test/routes-settings.test.ts b/backend/test/routes-settings.test.ts index 9a10d7b0..f415aa87 100644 --- a/backend/test/routes-settings.test.ts +++ b/backend/test/routes-settings.test.ts @@ -21,7 +21,7 @@ function appWithTmpSettings(extraEnv: Record = {}, deps = {}) { return { app, dir }; } -test("GET /settings returns effective defaults (env provider/model/thinking, first workspace)", async () => { +test("GET /settings returns thinking and the first workspace without legacy model defaults", async () => { const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, { listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], }); @@ -29,8 +29,8 @@ test("GET /settings returns effective defaults (env provider/model/thinking, fir const res = await app.inject({ method: "GET", url: "/settings" }); expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.provider).toBe("zai"); - expect(body.model).toBe("glm-5.2"); + expect(body).not.toHaveProperty("provider"); + expect(body).not.toHaveProperty("model"); expect(body.thinking).toBe("medium"); expect(typeof body.workspace).toBe("string"); // first workspace from ../harness/workspaces } finally { @@ -62,7 +62,9 @@ test("PUT /settings does not persist personal workspace or LLM choices", async ( }); expect(put.statusCode).toBe(200); const got = await app.inject({ method: "GET", url: "/settings" }); - expect(got.json()).toMatchObject({ provider: "zai", model: "glm-5.2", thinking: "medium" }); + expect(got.json()).toMatchObject({ thinking: "medium" }); + expect(got.json()).not.toHaveProperty("provider"); + expect(got.json()).not.toHaveProperty("model"); expect(got.json()).not.toMatchObject({ workspace: "psd", thinking: "high" }); } finally { rmSync(dir, { recursive: true, force: true }); @@ -114,7 +116,7 @@ test("settings no longer read or write principal-specific preferences", async () expect(preferences.size).toBe(0); }); -test("GET /settings retains complete legacy installation defaults without seeding a private profile", async () => { +test("GET /settings drops legacy installation model fields without seeding a private profile", async () => { let preferences: Record = {}; const writes: Record[] = []; const runner = { @@ -135,9 +137,7 @@ test("GET /settings retains complete legacy installation defaults without seedin const first = await app.inject({ method: "GET", url: "/settings" }); const second = await app.inject({ method: "GET", url: "/settings" }); - const expected = { - workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", - }; + const expected = { workspace: "local", thinking: "low" }; expect(first.statusCode).toBe(200); expect(first.json()).toEqual(expected); expect(second.json()).toEqual(expected); @@ -167,15 +167,13 @@ test("GET /settings ignores stale private preferences in favor of installation d const response = await app.inject({ method: "GET", url: "/settings" }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - workspace: "local", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", - }); + expect(response.json()).toEqual({ workspace: "local", thinking: "low" }); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("PUT /settings rejects an unknown model when a model list is available", async () => { +test("PUT /settings ignores a legacy unknown model because the catalog owns model validity", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], }); @@ -184,14 +182,14 @@ test("PUT /settings rejects an unknown model when a model list is available", as method: "PUT", url: "/settings", payload: { workspace: "psd", provider: "zai", model: "does-not-exist", thinking: "low" }, }); - expect(put.statusCode).toBe(400); - expect(put.json()).toMatchObject({ error: expect.stringMatching(/model/i) }); + expect(put.statusCode).toBe(200); + expect(put.json()).not.toHaveProperty("model"); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("PUT /settings validates provider and model as one composite identifier", async () => { +test("PUT /settings ignores legacy provider/model pairs", async () => { const { app, dir } = appWithTmpSettings({}, { listModels: async () => [ { provider: "provider-a", id: "shared-id", name: "A", reasoning: false }, @@ -204,7 +202,7 @@ test("PUT /settings validates provider and model as one composite identifier", a workspace: "psd", provider: "provider-b", model: "shared-id", thinking: "low", }, }); - expect(wrongProvider.statusCode).toBe(400); + expect(wrongProvider.statusCode).toBe(200); const exactPair = await app.inject({ method: "PUT", url: "/settings", @@ -213,6 +211,8 @@ test("PUT /settings validates provider and model as one composite identifier", a }, }); expect(exactPair.statusCode).toBe(200); + expect(exactPair.json()).not.toHaveProperty("provider"); + expect(exactPair.json()).not.toHaveProperty("model"); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/backend/test/routes-sql-meta.test.ts b/backend/test/routes-sql-meta.test.ts index f21dfefb..583b351f 100644 --- a/backend/test/routes-sql-meta.test.ts +++ b/backend/test/routes-sql-meta.test.ts @@ -1,4 +1,4 @@ -import { test, expect, vi } from "vitest"; +import { test, expect } from "vitest"; import Fastify from "fastify"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; @@ -156,12 +156,23 @@ test("registry-backed SQL preview resolves and uses the session's pinned runtime // Workspace registry route coverage lives in routes-workspaces.test.ts. `/workspaces` no longer // reads legacy harness files: the Git registry is the single shared source of truth. -test("GET /models returns {models:[...]} from injected listModels stub", async () => { +test("GET /models returns session choices from the installation model catalog", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, - listModels: async () => [ - { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, - ], + runtimeModelCatalog: { + defaultSession: "zai/glm-5.2", + defaultMetadataGeneration: null, + embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, + sessionModels: () => [{ + id: "zai/glm-5.2", provider: "zai", model: "glm-5.2", label: "GLM 5.2", + upstreamModel: "glm-5.2", authentication: { mode: "pi_auth" }, + sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: true }, + }], + metadataModels: () => [], + hasSession: (id: string) => id === "zai/glm-5.2", + }, + // Runtime introspection is a health gate for starting a session, not a second catalog. + listModels: async () => { throw new Error("Pi is unavailable"); }, }); const res = await app.inject({ method: "GET", url: "/models" }); @@ -172,36 +183,17 @@ test("GET /models returns {models:[...]} from injected listModels stub", async ( }); }); -test("GET /models returns {models:[]} when listModels throws (graceful fallback)", async () => { +test("GET /models returns an empty list when the catalog has no session models", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, - listModels: async () => { throw new Error("Pi not running"); }, - }); - - const res = await app.inject({ method: "GET", url: "/models" }); - - expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ models: [] }); -}); - -test("GET /models logs a sanitized warning when listing fails", async () => { - const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { - thtRunner: {} as any, - listModels: async () => { throw new Error("credential-value-must-not-appear"); }, - }); - const warn = vi.spyOn(app.log, "warn"); - - const res = await app.inject({ method: "GET", url: "/models" }); - - expect(res.json()).toEqual({ models: [] }); - expect(JSON.stringify(warn.mock.calls)).not.toContain("credential-value-must-not-appear"); - expect(warn).toHaveBeenCalled(); -}); - -test("GET /models with empty listModels stub returns empty array", async () => { - const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { - thtRunner: {} as any, - listModels: async () => [], + runtimeModelCatalog: { + defaultSession: null, + defaultMetadataGeneration: null, + embedding: null, + sessionModels: () => [], + metadataModels: () => [], + hasSession: () => false, + }, }); const res = await app.inject({ method: "GET", url: "/models" }); diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 92368e82..fd28f0a7 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -14,7 +14,7 @@ import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js" const workspace: CanonicalWorkspace = { workspace: { - schema_version: 3, + schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", description: "Clinical analytics workspace", @@ -26,20 +26,6 @@ const workspace: CanonicalWorkspace = { schema: "datawarehouse", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { - engine: "qdrant", - collection: "psd-clinical", - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }; const revision: WorkspaceRevision = { @@ -194,7 +180,7 @@ test("lists workspace summaries and reads a validated immutable workspace", asyn expect(read.json()).toEqual({ workspace, revision }); }); -test("validates a schema v3 workspace without mutating the repository", async () => { +test("validates a schema v4 workspace without mutating the repository", async () => { const app = appFor(registryFake()); const response = await app.inject({ @@ -284,7 +270,7 @@ test.each([1, 2])("rejects schema v%s at the validation boundary with a sanitize expect(response.body).not.toMatch(/migration_required|schema version/i); }); -test("runs diagnostics for a schema v3 workspace", async () => { +test("runs diagnostics for a schema v4 workspace", async () => { const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })); const app = appFor(registryFake(), diagnose); diff --git a/backend/test/settings-store.test.ts b/backend/test/settings-store.test.ts index a17ef315..953bf2d3 100644 --- a/backend/test/settings-store.test.ts +++ b/backend/test/settings-store.test.ts @@ -27,9 +27,9 @@ test("saveSettings writes the file and loadSettings reads it back", () => { const dir = mkdtempSync(join(tmpdir(), "tht-set-")); try { const cfg = cfgWith(join(dir, "nested", "settings.json")); - const saved = saveSettings(cfg, { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); - expect(saved.model).toBe("glm-5.2"); - expect(loadSettings(cfg)).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); + const saved = saveSettings(cfg, { workspace: "psd", thinking: "medium" }); + expect(saved.thinking).toBe("medium"); + expect(loadSettings(cfg)).toEqual({ workspace: "psd", thinking: "medium" }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -55,11 +55,11 @@ test("saveSettings restores the previous file when post-rename directory durabil const dir = mkdtempSync(join(tmpdir(), "tht-set-transaction-")); try { const cfg = cfgWith(join(dir, "settings.json")); - saveSettings(cfg, { provider: "old", model: "old-model", thinking: "low" }); + saveSettings(cfg, { thinking: "low" }); let syncs = 0; expect(() => saveSettings( cfg, - { provider: "new", model: "new-model", thinking: "high" }, + { thinking: "high" }, { syncDirectory(directory: string) { syncs += 1; @@ -69,7 +69,7 @@ test("saveSettings restores the previous file when post-rename directory durabil }, }, )).toThrow(/directory fsync failure/); - expect(loadSettings(cfg)).toEqual({ provider: "old", model: "old-model", thinking: "low" }); + expect(loadSettings(cfg)).toEqual({ thinking: "low" }); expect(syncs).toBeGreaterThanOrEqual(2); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/backend/test/tht-qdrant-readiness.test.ts b/backend/test/tht-qdrant-readiness.test.ts index 1cfc998a..7ca3175a 100644 --- a/backend/test/tht-qdrant-readiness.test.ts +++ b/backend/test/tht-qdrant-readiness.test.ts @@ -8,18 +8,11 @@ const keywordIndexes = [ ]; const workspace: CanonicalWorkspace = { - workspace: { schema_version: 3, id: "psd", name: "PSD", language: "it" }, + workspace: { schema_version: 4, id: "psd", name: "PSD", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "public", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { engine: "qdrant", collection: "psd", dimensions: 1024, distance: "cosine" }, - embedding: { - provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, - }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }; function runner(request: (...args: any[]) => Promise) { diff --git a/backend/test/workspace-preprocessing-service.test.ts b/backend/test/workspace-preprocessing-service.test.ts index 482d9f18..6afd05be 100644 --- a/backend/test/workspace-preprocessing-service.test.ts +++ b/backend/test/workspace-preprocessing-service.test.ts @@ -19,12 +19,13 @@ afterEach(() => { const semanticRuntime = { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", + internalEmbeddingId: "ollama/qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, }; const baseWorkspace = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Runtime Lease language: en @@ -33,21 +34,9 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `); const filesystemWorkspace = parseWorkspaceYaml(`${baseWorkspace ? '' : ''}workspace: - schema_version: 3 + schema_version: 4 id: fs-workspace name: Filesystem language: en @@ -56,25 +45,13 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: fs-workspace - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] evidence: source: type: filesystem uri: fs-workspace/evidence `); const privateHttpWorkspace = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: http-workspace name: Http language: en @@ -83,18 +60,6 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: http-workspace - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] evidence: source: type: http @@ -125,7 +90,7 @@ function runtime(workspace = baseWorkspace, workspaceId = workspace.workspace.id bindingDigest: "sha256:bindings", semanticQdrantUrl: "http://qdrant:6333", effectiveConfig: { - schemaVersion: 1, + schemaVersion: 2, dwh: { engine: "postgres", database: "analytics", @@ -136,7 +101,7 @@ function runtime(workspace = baseWorkspace, workspaceId = workspace.workspace.id user: "reader", }, vector: { collection: workspaceId, dimensions: 1024, distance: "cosine" }, - embedding: { model: "qwen3-embedding:0.6b", dimensions: 1024 }, + embedding: { id: "ollama/qwen3-embedding:0.6b", model: "qwen3-embedding:0.6b", dimensions: 1024 }, roots: { artifacts: "/data/artifacts", indexes: "/data/indexes" }, }, effectiveConfigIdentity: "workspace://psd-clinical@v1:" + "d".repeat(64), diff --git a/backend/test/workspace-preprocessing-state.test.ts b/backend/test/workspace-preprocessing-state.test.ts index b0e69788..6631d526 100644 --- a/backend/test/workspace-preprocessing-state.test.ts +++ b/backend/test/workspace-preprocessing-state.test.ts @@ -28,13 +28,15 @@ test("job state creates durable 0600 JSON and enforces same-revision resume", as catalogBlob: "c".repeat(40), configDigest: "sha256:config", bindingDigest: "sha256:bindings", + embeddingId: "ollama/qwen3-embedding:0.6b", + embeddingDimensions: 1024, }); const path = store.jobPath(job.runId); expect(existsSync(path)).toBe(true); expect(statSync(path).mode & 0o777).toBe(0o600); expect(JSON.parse(readFileSync(path, "utf8"))).toMatchObject({ - schemaVersion: 1, + schemaVersion: 2, operation: "preprocess dwh", workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40), @@ -42,6 +44,8 @@ test("job state creates durable 0600 JSON and enforces same-revision resume", as catalogBlob: "c".repeat(40), configDigest: "sha256:config", bindingDigest: "sha256:bindings", + embeddingId: "ollama/qwen3-embedding:0.6b", + embeddingDimensions: 1024, }); await expect(store.beginJob({ @@ -52,6 +56,20 @@ test("job state creates durable 0600 JSON and enforces same-revision resume", as catalogBlob: "c".repeat(40), configDigest: "sha256:config", bindingDigest: "sha256:bindings", + embeddingId: "ollama/qwen3-embedding:0.6b", + embeddingDimensions: 1024, + })).rejects.toMatchObject({ code: "preprocessing_resume_mismatch" }); + + await expect(store.beginJob({ + operation: "preprocess dwh", + runId: job.runId, + workspaceRevision: "a".repeat(40), + descriptorBlob: "b".repeat(40), + catalogBlob: "c".repeat(40), + configDigest: "sha256:config", + bindingDigest: "sha256:bindings", + embeddingId: "ollama/replacement-embedding", + embeddingDimensions: 1024, })).rejects.toMatchObject({ code: "preprocessing_resume_mismatch" }); const resumed = await store.beginJob({ @@ -62,6 +80,8 @@ test("job state creates durable 0600 JSON and enforces same-revision resume", as catalogBlob: "c".repeat(40), configDigest: "sha256:config", bindingDigest: "sha256:bindings", + embeddingId: "ollama/qwen3-embedding:0.6b", + embeddingDimensions: 1024, }); expect(resumed.runId).toBe(job.runId); }); diff --git a/backend/test/workspace-registry-deployment.test.ts b/backend/test/workspace-registry-deployment.test.ts index a405fb5a..270996d2 100644 --- a/backend/test/workspace-registry-deployment.test.ts +++ b/backend/test/workspace-registry-deployment.test.ts @@ -75,10 +75,6 @@ function workspaceVariant( return { ...workspace, workspace: { ...workspace.workspace, ...changes, id }, - semantic_index: { - ...workspace.semantic_index, - vector_store: { ...workspace.semantic_index.vector_store, collection: id }, - }, ...(workspace.evidence?.source.type === "filesystem" ? { evidence: { @@ -182,7 +178,7 @@ test("shared deployment fixtures remain valid standalone descriptors with canoni expect(smoke).toMatchObject({ workspace: { - schema_version: 3, + schema_version: 4, id: "local", name: "Local", description: "Isolated workspace registry smoke fixture.", @@ -193,14 +189,14 @@ test("shared deployment fixtures remain valid standalone descriptors with canoni }, }); expect(task13).toMatchObject({ - workspace: { schema_version: 3, id: "task13-smoke", name: "Task 13 Smoke" }, + workspace: { schema_version: 4, id: "task13-smoke", name: "Task 13 Smoke" }, evidence: { source: { type: "filesystem", uri: "task13-smoke/evidence", patterns: ["**/*.md"] }, policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, }, }); expect(windows).toMatchObject({ - workspace: { schema_version: 3, id: "task13-windows", name: "Task 13 Windows" }, + workspace: { schema_version: 4, id: "task13-windows", name: "Task 13 Windows" }, evidence: { source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] }, policy: { max_chunk_chars: 4000, retain_published_generations: 3 }, @@ -282,7 +278,7 @@ test("registry rejects orphan descriptors, metadata mismatches, and the retired }); }); -test("Windows clone contract copies the shared complete schema v3 descriptor into the nested registry layout", () => { +test("Windows clone contract copies the shared complete schema v4 descriptor into the nested registry layout", () => { const descriptor = parseWorkspaceYaml(readFixture("workspace-registry-windows.yaml")); const windows = readFileSync( new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url), @@ -299,7 +295,7 @@ test("Windows clone contract copies the shared complete schema v3 descriptor int expect(windows).not.toContain('schema_version: 3'); expect(descriptor).toMatchObject({ workspace: { - schema_version: 3, + schema_version: 4, id: "task13-windows", name: "Task 13 Windows", language: "en", diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 08b9163b..a8cafadb 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -15,7 +15,7 @@ import { import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -24,18 +24,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `; function withFilesystemEvidence(source: string, id = "psd-clinical"): string { @@ -146,7 +134,7 @@ function legacyV1Yaml(source = validYaml): string { .replace(" model: qwen3-embedding:0.6b", " model: nomic-embed-text-v2-moe") .replace(" dimensions: 1024", " dimensions: 768") .replace("distance: cosine\n", "distance: cosine\n supported_transports: [pgvector_direct]\n") - .replace("schema_version: 3", "schema_version: 1"); + .replace("schema_version: 4", "schema_version: 1"); } function legacyV2Yaml(source = validYaml): string { @@ -158,7 +146,7 @@ function legacyV2Yaml(source = validYaml): string { .replace(" model: qwen3-embedding:0.6b", " model: nomic-embed-text-v2-moe") .replace(" dimensions: 1024", " dimensions: 768") .replace("distance: cosine\n", "distance: cosine\n supported_transports: [pgvector_direct]\n") - .replace("schema_version: 3", "schema_version: 2"); + .replace("schema_version: 4", "schema_version: 2"); } const runFile = promisify(execFile); @@ -197,7 +185,7 @@ async function fixture(workspaceSource = validYaml): Promise<{ await git(source, ["init", "--initial-branch=main"]); await git(source, ["config", "user.name", "Workspace Registry Test"]); await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); - const workspace = workspaceSource.includes("schema_version: 3") + const workspace = workspaceSource.includes("schema_version: 4") ? parseWorkspaceYaml(workspaceSource) : undefined; mkdirSync(join(source, "psd-clinical"), { recursive: true }); writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml([ @@ -256,7 +244,7 @@ async function multiWorkspaceFixture(workspaces: Record): Promis await git(source, ["config", "user.email", "workspace-registry@example.invalid"]); const entries = []; for (const [id, workspaceSource] of Object.entries(workspaces)) { - const workspace = workspaceSource.includes("schema_version: 3") ? parseWorkspaceYaml(workspaceSource) : undefined; + const workspace = workspaceSource.includes("schema_version: 4") ? parseWorkspaceYaml(workspaceSource) : undefined; entries.push({ id, name: workspace.workspace.name, ...(workspace.workspace.description ? { description: workspace.workspace.description } : {}) }); mkdirSync(join(source, id), { recursive: true }); writeFileSync(join(source, id, "workspace.yaml"), workspaceSource); @@ -788,7 +776,7 @@ test("normalizes historical operational state during offline fallback after rest expect(read.revision).not.toHaveProperty("state"); }); -test("fails closed when a retained snapshot descriptor is not schema v3", async () => { +test("fails closed when a retained snapshot descriptor is not schema v4", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); @@ -820,45 +808,6 @@ test("keeps the last valid snapshot when a pulled commit has invalid YAML", asyn }); }); -test("rejects duplicate schema v3 collection ownership and keeps the previous active snapshot", async () => { - const v3Yaml = validYaml; - const remote = await multiWorkspaceFixture({ - "psd-clinical": v3Yaml, - "research-clinical": v3Yaml - .replace("id: psd-clinical", "id: research-clinical") - .replace("name: Policlinico San Donato", "name: Research Clinical") - .replace("collection: psd-clinical", "collection: research-clinical"), - }); - const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); - await registry.bootstrap(); - - writeFileSync( - join(remote.source, "research-clinical", "workspace.yaml"), - v3Yaml - .replace("id: psd-clinical", "id: research-clinical") - .replace("name: Policlinico San Donato", "name: Research Clinical") - .replace("collection: psd-clinical", "collection: shared"), - ); - writeFileSync( - join(remote.source, "psd-clinical", "workspace.yaml"), - v3Yaml.replace("collection: psd-clinical", "collection: shared"), - ); - await git(remote.source, ["add", "-A"]); - await git(remote.source, ["commit", "-m", "Duplicate collection ownership"]); - await git(remote.source, ["push", "origin", "main"]); - - await expect(registry.pull()).rejects.toMatchObject({ - code: "workspace_invalid", - message: "Workspace repository content is invalid", - }); - await expect(registry.read("psd-clinical")).resolves.toMatchObject({ - revision: { commit: remote.initialCommit }, - }); - await expect(registry.read("research-clinical")).resolves.toMatchObject({ - revision: { commit: remote.initialCommit }, - }); -}); - test("retains a historical snapshot while a resumable manifest still references its revision", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index f7bae6af..ebb745ae 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -66,7 +66,7 @@ workspaces: [{id: psd-clinical, name: Runtime Lease}] `); mkdirSync(join(source, "psd-clinical", "evidence"), { recursive: true }); writeFileSync(join(source, "psd-clinical", "workspace.yaml"), `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Runtime Lease language: en @@ -75,18 +75,6 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] evidence: source: type: filesystem diff --git a/backend/test/workspace-runtime-handoff.test.ts b/backend/test/workspace-runtime-handoff.test.ts index d389b40c..4484f514 100644 --- a/backend/test/workspace-runtime-handoff.test.ts +++ b/backend/test/workspace-runtime-handoff.test.ts @@ -21,7 +21,7 @@ const thtBin = join(harnessDir, ".venv", "bin", "tht"); const roots: string[] = []; const canonicalWorkspace = `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Runtime handoff language: en @@ -30,18 +30,6 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `; const filesystemWorkspace = `${canonicalWorkspace}evidence: @@ -145,7 +133,7 @@ function runnerFor(f: Awaited>): ThtRunner { } as any); } -test("real schema-v3 registry revision loads through ThtRunner and the harness contract", async () => { +test("real schema-v4 registry revision loads through ThtRunner and the harness contract", async () => { const f = await fixture(); const runner = runnerFor(f); diff --git a/backend/test/workspace-runtime-renderer.test.ts b/backend/test/workspace-runtime-renderer.test.ts index d5d3c5ea..2fa1107f 100644 --- a/backend/test/workspace-runtime-renderer.test.ts +++ b/backend/test/workspace-runtime-renderer.test.ts @@ -12,8 +12,8 @@ import { import { supportsSessionRuntime } from "../src/workspaces/bindings.js"; import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; -const workspaceV3 = parseWorkspaceYaml(`workspace: - schema_version: 3 +const workspaceV4 = parseWorkspaceYaml(`workspace: + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -22,19 +22,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct, rest_api, ssh_tunnel] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - default: zai/glm-5.2 - allowed: [zai/glm-5.2] `); const paths: RuntimePaths = { sessions: "/data/workspaces/psd-clinical/sessions", @@ -45,6 +32,7 @@ const paths: RuntimePaths = { const semanticRuntime: SemanticRuntimeConfig = { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", + internalEmbeddingId: "ollama/qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, }; @@ -63,8 +51,8 @@ const directBindings: RuntimeBindings = { evidence: { missing: [], values: {} }, }; -test("renders only the schema-v3 internal Qdrant and Ollama runtime shape", () => { - const rendered = parse(renderRuntimeConfig(workspaceV3, directBindings, paths, { +test("derives the internal Qdrant and Ollama runtime shape from workspace v4 plus installation config", () => { + const rendered = parse(renderRuntimeConfig(workspaceV4, directBindings, paths, { workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40), }, {}, semanticRuntime)); @@ -95,8 +83,8 @@ test("renders only the schema-v3 internal Qdrant and Ollama runtime shape", () = expect(rendered).not.toHaveProperty("vector_rest"); }); -test("renders schema-v3 DWH REST without exposing secret contents", () => { - const rendered = parse(renderRuntimeConfig(workspaceV3, { +test("renders workspace-v4 DWH REST without exposing secret contents", () => { + const rendered = parse(renderRuntimeConfig(workspaceV4, { dwh: { transport: "rest_api", missing: [], values: { THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test", @@ -152,7 +140,7 @@ function evidenceWorkspace( } const canonicalEvidenceWorkspace = `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Runtime Evidence language: en @@ -161,18 +149,6 @@ dwh: database: analytics schema: mart supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `; const evidenceRevision = "1".repeat(40); @@ -374,7 +350,7 @@ test("renders static S3 Evidence with endpoint policy, limits, and file paths bu test("omits Evidence configuration and policy when the descriptor has no Evidence", () => { const rendered = parse(renderRuntimeConfig( - workspaceV3, + workspaceV4, directBindings, paths, evidenceContext, diff --git a/backend/test/workspace-secret-requirements.test.ts b/backend/test/workspace-secret-requirements.test.ts index 58d6582a..f7505b3e 100644 --- a/backend/test/workspace-secret-requirements.test.ts +++ b/backend/test/workspace-secret-requirements.test.ts @@ -14,7 +14,7 @@ const roots: string[] = []; function workspace(extra = "") { return parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: en @@ -23,10 +23,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct, rest_api, ssh_tunnel] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } ${extra}`); } diff --git a/backend/test/workspace-v4-migration.test.ts b/backend/test/workspace-v4-migration.test.ts new file mode 100644 index 00000000..d4c0baa0 --- /dev/null +++ b/backend/test/workspace-v4-migration.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { migrateWorkspaceV3Yaml, parseWorkspaceYaml } from "../src/workspaces/schema.js"; + +const legacy = `workspace: + schema_version: 3 + id: abc + name: Example + language: en +dwh: + engine: postgres + database: warehouse + schema: public + supported_transports: [postgres_direct] +semantic_index: + vector_store: {engine: qdrant, collection: abc, dimensions: 1024, distance: cosine} + embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024} +llm_policy: + default: zai/glm-5.3 + allowed: [zai/glm-5.3] +`; + +test("strict workspace v4 rejects model-bearing v3 descriptors", () => { + expect(() => parseWorkspaceYaml(legacy)).toThrow(); +}); + +test("v3 to v4 migration removes only model/vector policy and bumps the version", () => { + const migrated = migrateWorkspaceV3Yaml(legacy); + const workspace = parseWorkspaceYaml(migrated); + + expect(workspace.workspace).toMatchObject({ schema_version: 4, id: "abc" }); + expect(migrated).not.toMatch(/semantic_index|llm_policy/); + expect(workspace.dwh).toEqual({ + engine: "postgres", database: "warehouse", schema: "public", + supported_transports: ["postgres_direct"], + }); +}); diff --git a/backend/test/workspaces-bindings.test.ts b/backend/test/workspaces-bindings.test.ts index 5b9209e8..30f4e966 100644 --- a/backend/test/workspaces-bindings.test.ts +++ b/backend/test/workspaces-bindings.test.ts @@ -9,8 +9,8 @@ import { } from "../src/workspaces/bindings.js"; import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; -const workspaceV3 = parseWorkspaceYaml(`workspace: - schema_version: 3 +const workspaceV4 = parseWorkspaceYaml(`workspace: + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -19,10 +19,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct, rest_api, ssh_tunnel] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } `); const temporaryRoots: string[] = []; @@ -40,9 +36,9 @@ function secretPath(name: string): { root: string; path: string } { return { root: secrets, path }; } -test("resolves schema-v3 direct DWH bindings from the stable namespace", () => { +test("resolves workspace-v4 direct DWH bindings from the stable namespace", () => { const password = secretPath("dwh-password"); - const result = resolveBinding(workspaceV3, "DWH", { + const result = resolveBinding(workspaceV4, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", THT_WS_PSD_CLINICAL_DWH_PORT: "5432", @@ -61,14 +57,14 @@ test("resolves schema-v3 direct DWH bindings from the stable namespace", () => { }); }); -test("requires schema-v3 REST credentials unless the DWH diagnostic declares auth none", () => { - expect(resolveBinding(workspaceV3, "DWH", { +test("requires workspace-v4 REST credentials unless the DWH diagnostic declares auth none", () => { + expect(resolveBinding(workspaceV4, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api", THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test", }, []).missing).toContain("THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE"); const noAuth = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: No auth language: en @@ -77,16 +73,12 @@ dwh: database: postgres schema: public supported_transports: [rest_api] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } diagnostics: dwh_rest: method: GET path: /health auth: none response: { database: database, schema: schema } -llm_policy: { allowed: [zai/glm-5.2] } `); expect(resolveBinding(noAuth, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api", @@ -98,7 +90,7 @@ test("rejects unsupported transports and secret paths outside configured roots", const outside = secretPath("outside-password"); const allowed = secretPath("allowed-password"); const directOnly = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Direct only language: en @@ -107,15 +99,11 @@ dwh: database: postgres schema: public supported_transports: [postgres_direct] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } `); expect(resolveBinding(directOnly, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api", }, []).missing).toContain("THT_WS_PSD_CLINICAL_DWH_TRANSPORT"); - const result = resolveBinding(workspaceV3, "DWH", { + const result = resolveBinding(workspaceV4, "DWH", { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", THT_WS_PSD_CLINICAL_DWH_PORT: "5432", @@ -128,7 +116,7 @@ llm_policy: { allowed: [zai/glm-5.2] } test("runtime bindings contain only DWH and Evidence roles", () => { const password = secretPath("dwh-password"); - const bindings = resolveRuntimeBindings(workspaceV3, { + const bindings = resolveRuntimeBindings(workspaceV4, { THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct", THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal", THT_WS_PSD_CLINICAL_DWH_PORT: "5432", @@ -143,7 +131,7 @@ test("runtime bindings contain only DWH and Evidence roles", () => { function withEvidence(source: Record) { return parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -152,10 +140,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } evidence: source: ${JSON.stringify(source)} `); @@ -260,7 +244,7 @@ test("rejects relative, missing, directory, unreadable, and escaping symlink Evi }); test("includes Evidence binding completeness in session runtime support without changing v3 compatibility", () => { - const unsigned = resolveRuntimeBindings(workspaceV3, {}, ["/run/secrets"]); + const unsigned = resolveRuntimeBindings(workspaceV4, {}, ["/run/secrets"]); expect(unsigned.evidence).toEqual({ values: {}, missing: [] }); expect(supportsSessionRuntime(unsigned)).toBe(true); diff --git a/backend/test/workspaces-catalog.test.ts b/backend/test/workspaces-catalog.test.ts index 23b71a66..222ce799 100644 --- a/backend/test/workspaces-catalog.test.ts +++ b/backend/test/workspaces-catalog.test.ts @@ -7,7 +7,7 @@ import { } from "../src/workspaces/catalog.js"; const descriptor = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd name: Policlinico San Donato description: Clinical warehouse @@ -17,10 +17,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [rest_api] -semantic_index: - vector_store: { engine: qdrant, collection: psd, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } `); test("parses the strict ordered root catalog", () => { diff --git a/backend/test/workspaces-contracts.test.ts b/backend/test/workspaces-contracts.test.ts index 261a5a90..b857b9bf 100644 --- a/backend/test/workspaces-contracts.test.ts +++ b/backend/test/workspaces-contracts.test.ts @@ -5,8 +5,8 @@ import { join } from "node:path"; import { buildInstallationContract, renderWorkspaceDocs } from "../src/workspaces/contracts.js"; import { type CanonicalWorkspace, parseWorkspaceYaml } from "../src/workspaces/schema.js"; -const workspaceV3 = parseWorkspaceYaml(`workspace: - schema_version: 3 +const workspaceV4 = parseWorkspaceYaml(`workspace: + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -15,17 +15,12 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct, rest_api, ssh_tunnel] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: - allowed: [zai/glm-5.2] `); -test("schema-v3 installation contracts expose only DWH bindings and no semantic variables", () => { - const contract = buildInstallationContract(workspaceV3); +test("workspace-v4 installation contracts expose only DWH bindings and no semantic variables", () => { + const contract = buildInstallationContract(workspaceV4); const names = contract.variables.map((variable) => variable.name); - const docs = renderWorkspaceDocs(workspaceV3); + const docs = renderWorkspaceDocs(workspaceV4); expect(contract.workspaceId).toBe("psd-clinical"); expect(contract.namespace).toBe("PSD_CLINICAL"); @@ -54,22 +49,22 @@ test.each([ test("validates public contract and documentation inputs at runtime", () => { const unsafeWorkspace = { - ...workspaceV3, - workspace: { ...workspaceV3.workspace, id: "psd\nclinical" }, + ...workspaceV4, + workspace: { ...workspaceV4.workspace, id: "psd\nclinical" }, } as CanonicalWorkspace; expect(() => buildInstallationContract(unsafeWorkspace)).toThrow(/id/i); expect(() => renderWorkspaceDocs(unsafeWorkspace)).toThrow(/id/i); }); -test("v3 installation contract omits external vector and embedding bindings", () => { - const contract = buildInstallationContract(workspaceV3); +test("v4 installation contract omits external vector and embedding bindings", () => { + const contract = buildInstallationContract(workspaceV4); const names = contract.variables.map((variable) => variable.name); expect(names).toContain("THT_WS_PSD_CLINICAL_DWH_TRANSPORT"); expect(names.some((name) => name.includes("_VECTOR_"))).toBe(false); expect(names.some((name) => name.includes("_EMBEDDING_"))).toBe(false); - expect(renderWorkspaceDocs(workspaceV3).markdown).not.toContain("Embedding service"); + expect(renderWorkspaceDocs(workspaceV4).markdown).not.toContain("Embedding service"); }); @@ -114,7 +109,7 @@ test.each([ function renderWorkspaceWithoutEvidence(): string { return `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -123,10 +118,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } -llm_policy: { allowed: [zai/glm-5.2] } `; } diff --git a/backend/test/workspaces-diagnostics.test.ts b/backend/test/workspaces-diagnostics.test.ts index 16c82087..6ebd09ea 100644 --- a/backend/test/workspaces-diagnostics.test.ts +++ b/backend/test/workspaces-diagnostics.test.ts @@ -12,7 +12,7 @@ import type { RuntimeBindings } from "../src/workspaces/runtime-renderer.js"; import { parseWorkspaceYaml, resolveDiagnosticUrl } from "../src/workspaces/schema.js"; const workspace = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -22,18 +22,6 @@ dwh: schema: datawarehouse timeout_ms: 8000 supported_transports: [postgres_direct, rest_api, ssh_tunnel] -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] `); const bindings: RuntimeBindings = { @@ -78,7 +66,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -test("diagnoses schema-v3 DWH, internal Qdrant, and internal Ollama without semantic bindings", async () => { +test("diagnoses workspace-v4 DWH plus installation-derived Qdrant and Ollama", async () => { const adapters = successfulAdapters(); const result = await diagnose(adapters)(workspace, bindings, { writeProbe: false }); @@ -139,7 +127,7 @@ test("reports only sanitized DWH and Evidence binding names before network diagn expect(adapters.inspectQdrant).not.toHaveBeenCalled(); }); -test("keeps schema-v3 DWH SSH diagnostic-only and runtime-inactive", async () => { +test("keeps workspace-v4 DWH SSH diagnostic-only and runtime-inactive", async () => { const adapters = successfulAdapters(); const result = await diagnose(adapters)(workspace, { ...bindings, @@ -152,9 +140,9 @@ test("keeps schema-v3 DWH SSH diagnostic-only and runtime-inactive", async () => expect(adapters.probeConnector).not.toHaveBeenCalled(); }); -test("uses the schema-v3 declared DWH REST diagnostic and auth policy", async () => { +test("uses the workspace-v4 declared DWH REST diagnostic and auth policy", async () => { const restWorkspace = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: REST workspace language: en @@ -163,16 +151,12 @@ dwh: database: warehouse schema: datawarehouse supported_transports: [rest_api] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } diagnostics: dwh_rest: method: POST path: /rpc/ping auth: bearer response: { database: database, schema: schema } -llm_policy: { allowed: [zai/glm-5.2] } `); const adapters = successfulAdapters(); const result = await diagnose(adapters)(restWorkspace, { @@ -423,7 +407,7 @@ test("uses a REST secret only as a header and redacts it from failed diagnostics const canary = "CANARY-REST-AUTH-SECRET"; await writeFile(credentialFile, canary); const restDescriptor = parseWorkspaceYaml(`workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: REST auth language: en @@ -432,16 +416,12 @@ dwh: database: warehouse schema: datawarehouse supported_transports: [rest_api] -semantic_index: - vector_store: { engine: qdrant, collection: psd-clinical, dimensions: 1024, distance: cosine } - embedding: { provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024 } diagnostics: dwh_rest: method: GET path: /health auth: bearer response: { database: database, schema: schema } -llm_policy: { allowed: [zai/glm-5.2] } `); const fetchMock = vi.fn() .mockResolvedValueOnce(new Response("upstream CANARY-REST-AUTH-SECRET", { status: 503 })) diff --git a/backend/test/workspaces-git-annotations.test.ts b/backend/test/workspaces-git-annotations.test.ts index 82957e4e..84842670 100644 --- a/backend/test/workspaces-git-annotations.test.ts +++ b/backend/test/workspaces-git-annotations.test.ts @@ -53,7 +53,7 @@ async function makeRepo(id: string, annotations: string | Buffer | "dir" | "syml writeFileSync(join(source, "thoth-workspaces.yaml"), `schema_version: 1\nworkspaces: [{id: ${id}, name: Workspace}]\n`); mkdirSync(join(source, id, "schema"), { recursive: true }); - writeFileSync(join(source, id, "workspace.yaml"), `workspace:\n schema_version: 3\n id: ${id}\n`); + writeFileSync(join(source, id, "workspace.yaml"), `workspace:\n schema_version: 4\n id: ${id}\n`); const annotationsPath = join(source, id, "schema", "annotations.yaml"); if (annotations === "dir") { mkdirSync(annotationsPath, { recursive: true }); diff --git a/backend/test/workspaces-git-evidence.test.ts b/backend/test/workspaces-git-evidence.test.ts index 9ad14517..4c050cca 100644 --- a/backend/test/workspaces-git-evidence.test.ts +++ b/backend/test/workspaces-git-evidence.test.ts @@ -47,7 +47,7 @@ async function fixture(layout: EvidenceLayout): Promise<{ root: string; remote: await git(source, ["config", "user.email", "evidence@example.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: research, name: Research}]\n"); mkdirSync(join(source, "research"), { recursive: true }); - writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 3\n id: research\n"); + writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 4\n id: research\n"); const evidence = join(source, "research", "evidence"); if (layout === "tree") { mkdirSync(join(evidence, "nested"), { recursive: true }); diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index 659b921a..86eb5e5d 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -12,7 +12,7 @@ import { import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: - schema_version: 2 + schema_version: 4 id: psd-clinical name: Policlinico San Donato language: it @@ -21,21 +21,6 @@ dwh: database: postgres schema: datawarehouse supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: pgvector - database: postgres - schema: vectors - collection: clinical_documents - dimensions: 768 - distance: cosine - supported_transports: [pgvector_direct] - embedding: - provider: ollama_compatible - model: nomic-embed-text-v2-moe - dimensions: 768 -llm_policy: - allowed: [zai/glm-5.2] `; const runFile = promisify(execFile); diff --git a/backend/test/workspaces-runtime-v3-boundaries.test.ts b/backend/test/workspaces-runtime-v4-boundaries.test.ts similarity index 77% rename from backend/test/workspaces-runtime-v3-boundaries.test.ts rename to backend/test/workspaces-runtime-v4-boundaries.test.ts index 022f9015..35d74173 100644 --- a/backend/test/workspaces-runtime-v3-boundaries.test.ts +++ b/backend/test/workspaces-runtime-v4-boundaries.test.ts @@ -13,20 +13,11 @@ import { import { renderRuntimeConfig, type RuntimeBindings } from "../src/workspaces/runtime-renderer.js"; const unsupportedWorkspace = { - workspace: { schema_version: 2, id: "legacy-workspace", name: "Legacy", language: "en" }, + workspace: { schema_version: 3, id: "legacy-workspace", name: "Legacy", language: "en" }, dwh: { engine: "postgres", database: "warehouse", schema: "public", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { - engine: "pgvector", database: "warehouse", schema: "vectors", - collection: "documents", dimensions: 768, distance: "cosine", - supported_transports: ["pgvector_direct"], - }, - embedding: { provider: "ollama_compatible", model: "legacy", dimensions: 768 }, - }, - llm_policy: { allowed: ["zai/glm-5.2"] }, }; const bindings: RuntimeBindings = { @@ -40,31 +31,31 @@ const adapters: DiagnosticAdapters = { probeEmbedding: vi.fn(), }; -test("renderer rejects callers that bypass the schema-v3 type contract", () => { +test("renderer rejects callers that bypass the schema-v4 type contract", () => { expect(() => renderRuntimeConfig(unsupportedWorkspace as never, bindings, { sessions: "/data/sessions", artifacts: "/data/artifacts", indexes: "/data/indexes", - })).toThrow("Runtime renderer supports only workspace schema version 3"); + })).toThrow("Runtime renderer supports only workspace schema version 4"); }); -test("installation contract rejects callers that bypass the schema-v3 type contract", () => { +test("installation contract rejects callers that bypass the schema-v4 type contract", () => { expect(() => buildInstallationContract(unsupportedWorkspace as never)) - .toThrow("Installation contract supports only workspace schema version 3"); + .toThrow("Installation contract supports only workspace schema version 4"); }); -test("binding entry points reject callers that bypass the schema-v3 type contract", () => { +test("binding entry points reject callers that bypass the schema-v4 type contract", () => { expect(() => resolveBinding(unsupportedWorkspace as never, "DWH", {}, [])) - .toThrow("Workspace bindings support only workspace schema version 3"); + .toThrow("Workspace bindings support only workspace schema version 4"); expect(() => resolveEvidenceBinding(unsupportedWorkspace as never, {}, [])) - .toThrow("Workspace bindings support only workspace schema version 3"); + .toThrow("Workspace bindings support only workspace schema version 4"); expect(() => resolveRuntimeBindings(unsupportedWorkspace as never, {}, [])) - .toThrow("Workspace bindings support only workspace schema version 3"); + .toThrow("Workspace bindings support only workspace schema version 4"); }); -test("diagnoser factories reject callers that bypass the schema-v3 type contract", async () => { +test("diagnoser factories reject callers that bypass the schema-v4 type contract", async () => { await expect(createWorkspaceDiagnoser(adapters)(unsupportedWorkspace as never, bindings, { writeProbe: false, - })).rejects.toThrow("Workspace diagnoser supports only workspace schema version 3"); + })).rejects.toThrow("Workspace diagnoser supports only workspace schema version 4"); await expect(createProductionWorkspaceDiagnoser(5_000, adapters)( unsupportedWorkspace as never, bindings, { writeProbe: false }, - )).rejects.toThrow("Workspace diagnoser supports only workspace schema version 3"); + )).rejects.toThrow("Workspace diagnoser supports only workspace schema version 4"); }); diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index 07243da6..02e35eac 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -10,7 +10,7 @@ import { } from "../src/workspaces/schema.js"; export const validYaml = `workspace: - schema_version: 3 + schema_version: 4 id: psd-clinical name: Policlinico San Donato description: Clinical data warehouse workspace @@ -25,33 +25,8 @@ dwh: - postgres_direct - rest_api - ssh_tunnel -semantic_index: - vector_store: - engine: qdrant - collection: psd-clinical - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - default: zai/glm-5.2 - allowed: - - zai/glm-5.2 - - openai/gpt-5 `; -test("rejects a workspace whose embedding dimensions differ from its collection", () => { - expect(() => parseWorkspaceYaml(validYaml.replace("dimensions: 1024", "dimensions: 1536"))) - .toThrow(/dimensions/i); -}); - -test("rejects an LLM default outside its allowlist", () => { - expect(() => parseWorkspaceYaml(validYaml.replace("- zai/glm-5.2", "- openai/gpt-5"))) - .toThrow(/allowlist/i); -}); - test("rejects unknown keys and invalid immutable IDs", () => { expect(() => parseWorkspaceYaml(validYaml.replace(" language: it", " language: it\n label: PSD"))) .toThrow(/unrecognized key/i); @@ -74,94 +49,34 @@ test("accepts optional connection ports and timeouts but rejects unsafe values", .toThrow(/port/i); expect(() => parseWorkspaceYaml(validYaml.replace("timeout_ms: 5000", "timeout_ms: 0"))) .toThrow(/timeout/i); - expect(() => parseWorkspaceYaml(validYaml.replace("dimensions: 1024", "dimensions: 2048"))) - .toThrow(/1024|dimensions/i); }); -test("accepts only the schema v3 internal qdrant semantic shape", () => { +test("accepts a model-free schema v4 workspace", () => { expect(parseWorkspaceYaml(validYaml)).toMatchObject({ - workspace: { schema_version: 3, id: "psd-clinical" }, - semantic_index: { - vector_store: { - engine: "qdrant", - collection: "psd-clinical", - dimensions: 1024, - distance: "cosine", - }, - embedding: { - provider: "ollama_internal", - model: "qwen3-embedding:0.6b", - dimensions: 1024, - }, - }, + workspace: { schema_version: 4, id: "psd-clinical" }, + dwh: { database: "postgres", schema: "datawarehouse" }, }); }); -test("committed example descriptors parse as exact schema v3 workspaces", () => { +test("committed example descriptors parse as exact schema v4 workspaces", () => { const example = readFileSync(resolve(process.cwd(), "../deploy/workspaces/example.yaml"), "utf8"); const psdExample = readFileSync(resolve(process.cwd(), "../deploy/workspaces/psd.yaml.example"), "utf8"); expect(() => parseWorkspaceYaml(example)).not.toThrow(); expect(() => parseWorkspaceYaml(psdExample)).not.toThrow(); expect(parseWorkspaceYaml(example)).toMatchObject({ - workspace: { schema_version: 3 }, - semantic_index: { - vector_store: { engine: "qdrant", distance: "cosine", dimensions: 1024 }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, + workspace: { schema_version: 4 }, }); expect(parseWorkspaceYaml(psdExample)).toMatchObject({ - workspace: { schema_version: 3 }, - semantic_index: { - vector_store: { engine: "qdrant", distance: "cosine", dimensions: 1024 }, - embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, - }, + workspace: { schema_version: 4 }, }); }); -test("rejects pgvector semantic stores in schema v3", () => { - expect(() => parseWorkspaceYaml(validYaml.replace("engine: qdrant", "engine: pgvector"))) - .toThrow(/qdrant|pgvector/i); -}); - -test("rejects supported_transports inside schema v3 semantic identity", () => { - const withTransport = validYaml.replace( - " distance: cosine\n", - " distance: cosine\n supported_transports:\n - rest_api\n", - ); - - expect(() => parseWorkspaceYaml(withTransport)).toThrow(/unrecognized key|supported_transports/i); -}); - -test("rejects external embedding providers in schema v3", () => { - expect(() => parseWorkspaceYaml(validYaml.replace("provider: ollama_internal", "provider: openai_compatible"))) - .toThrow(/ollama_internal|provider/i); -}); - -test("rejects non-cosine distance in schema v3", () => { - expect(() => parseWorkspaceYaml(validYaml.replace("distance: cosine", "distance: l2"))) - .toThrow(/cosine|distance/i); -}); - -test("rejects unknown fields in schema v3 semantic identity", () => { - const withUnknownField = validYaml.replace( - " collection: psd-clinical\n", - " collection: psd-clinical\n namespace: psd\n", - ); - - expect(() => parseWorkspaceYaml(withUnknownField)).toThrow(/unrecognized key/i); -}); - -test("rejects legacy semantic connector fields and diagnostics in schema v3", () => { - expect(() => parseWorkspaceYaml(validYaml.replace( - " collection: psd-clinical\n", - " collection: psd-clinical\n database: postgres\n", - ))).toThrow(/unrecognized key|database/i); - - expect(() => parseWorkspaceYaml(validYaml.replace( - "llm_policy:\n", - "diagnostics:\n vector_rest:\n metadata:\n method: GET\n path: /metadata\n auth: bearer\n response:\n collection: collection\n dimensions: dimensions\n distance: distance\nllm_policy:\n", - ))).toThrow(/unrecognized key|vector_rest/i); +test("rejects installation-owned model and vector fields", () => { + expect(() => parseWorkspaceYaml(`${validYaml}llm_policy:\n allowed: [zai/glm-5.3]\n`)) + .toThrow(/unrecognized key|llm_policy/i); + expect(() => parseWorkspaceYaml(`${validYaml}semantic_index: {}\n`)) + .toThrow(/unrecognized key|semantic_index/i); }); test.each([ @@ -222,8 +137,8 @@ llm_policy: - zai/glm-5.2 `], ])("rejects schema %s descriptors at parser and object-validator boundaries", (_version, yaml) => { - expect(() => parseWorkspaceYaml(yaml)).toThrow(/schema_version|invalid literal|3/i); - expect(() => validateWorkspaceDescriptor(parse(yaml))).toThrow(/schema_version|invalid literal|3/i); + expect(() => parseWorkspaceYaml(yaml)).toThrow(/schema_version|invalid literal|4/i); + expect(() => validateWorkspaceDescriptor(parse(yaml))).toThrow(/schema_version|invalid literal|4/i); }); test("does not expose the redundant canonical validator or v1 migration", () => { @@ -253,9 +168,8 @@ test("rejects REST diagnostic declarations without their matching connector tran response: database: database schema: schema -llm_policy: `; - const declared = validYaml.replace("llm_policy:\n", diagnostics); + const declared = `${validYaml}${diagnostics}`; expect(() => parseWorkspaceYaml(declared.replace(" - rest_api\n", ""))).toThrow(/dwh_rest/i); }); @@ -462,7 +376,7 @@ test.each(["curated/**/*.yaml", "curated/**"])( }, ); -test("keeps evidence optional on schema v3", () => { +test("keeps evidence optional on schema v4", () => { expect(validateWorkspaceDescriptor(validWorkspaceObject())).not.toHaveProperty("evidence"); }); @@ -471,7 +385,7 @@ test("serializes defaulted evidence canonically and parses it without loss", () type: "filesystem", uri: "psd-clinical/evidence", })); - if (canonical.workspace.schema_version !== 3) throw new Error("expected schema v3"); + if (canonical.workspace.schema_version !== 4) throw new Error("expected schema v4"); expect(parseWorkspaceYaml(serializeWorkspaceYaml(canonical))).toEqual(canonical); }); diff --git a/backend/test/workspaces/evidence/materialization.test.ts b/backend/test/workspaces/evidence/materialization.test.ts index 72490637..f9ed087e 100644 --- a/backend/test/workspaces/evidence/materialization.test.ts +++ b/backend/test/workspaces/evidence/materialization.test.ts @@ -46,7 +46,7 @@ async function fixture(): Promise<{ root: string; remote: string; commit: string await git(source, ["config", "user.email", "evidence-materializer@example.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: research, name: Research}]\n"); mkdirSync(join(source, "research", "evidence", "nested"), { recursive: true }); - writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 3\n id: research\n"); + writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 4\n id: research\n"); writeFileSync(join(source, "research", "evidence", "guide.md"), "# guide\n"); writeFileSync(join(source, "research", "evidence", "nested", "deep.md"), "# deep\n"); await git(source, ["add", "-A"]); @@ -102,7 +102,7 @@ test("refuses symlink-containing trees and bound violations without publishing", await git(source, ["config", "user.email", "e@e.invalid"]); writeFileSync(join(source, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: research, name: Research}]\n"); mkdirSync(join(source, "research", "evidence"), { recursive: true }); - writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 3\n id: research\n"); + writeFileSync(join(source, "research", "workspace.yaml"), "workspace:\n schema_version: 4\n id: research\n"); writeFileSync(join(source, "research", "outside.md"), "# outside\n"); symlinkSync("../outside.md", join(source, "research", "evidence", "link.md")); await git(source, ["add", "-A"]); diff --git a/compose.yaml b/compose.yaml index 06721e9c..b9c5e222 100644 --- a/compose.yaml +++ b/compose.yaml @@ -36,15 +36,11 @@ services: THT_LLM_URL: ${THT_LLM_URL:-} THT_INTERNAL_QDRANT_URL: http://qdrant:6333 THT_INTERNAL_EMBEDDING_URL: http://embedding:11434 - THT_INTERNAL_EMBEDDING_MODEL: qwen3-embedding:0.6b - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024" MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4} volumes: - settings:/data/settings - pi-state:/home/thoth/.pi - ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro - - ./deploy/pi/models.json:/home/thoth/.pi/agent/models.json:ro - - ./deploy/pi/settings.json:/home/thoth/.pi/agent/settings.json:ro - workspace-registry:/data/workspace-registry - workspace-secrets:/data/workspace-secrets - sessions:/data/sessions @@ -140,8 +136,6 @@ services: THT_LLM_URL: ${THT_LLM_URL:-} THT_INTERNAL_QDRANT_URL: http://qdrant:6333 THT_INTERNAL_EMBEDDING_URL: http://embedding:11434 - THT_INTERNAL_EMBEDDING_MODEL: qwen3-embedding:0.6b - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024" HOME: /tmp/thoth AWS_ACCESS_KEY_ID: "" AWS_SECRET_ACCESS_KEY: "" @@ -243,7 +237,6 @@ services: entrypoint: ["/usr/bin/bash", "/opt/thoth/embedding-model-init.sh"] environment: OLLAMA_BASE_URL: http://embedding:11434 - OLLAMA_MODEL: qwen3-embedding:0.6b OLLAMA_WAIT_TIMEOUT_SEC: "180" volumes: - embedding-models:/root/.ollama diff --git a/deploy/compose.server.yaml b/deploy/compose.server.yaml index 4bde7877..02ffa967 100644 --- a/deploy/compose.server.yaml +++ b/deploy/compose.server.yaml @@ -21,14 +21,6 @@ services: source: ${PI_AUTH_FILE:?set PI_AUTH_FILE} target: /home/thoth/.pi/agent/auth.json read_only: true - - type: bind - source: ./deploy/pi/models.json - target: /home/thoth/.pi/agent/models.json - read_only: true - - type: bind - source: ./deploy/pi/settings.json - target: /home/thoth/.pi/agent/settings.json - read_only: true - type: bind source: ${THT_WORKSPACE_REGISTRY_ROOT:?set THT_WORKSPACE_REGISTRY_ROOT} target: /data/workspace-registry diff --git a/deploy/pi/models.json b/deploy/pi/models.json deleted file mode 100644 index 591d2da8..00000000 --- a/deploy/pi/models.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "providers": { - "zai": { - "baseUrl": "https://api.z.ai/api/coding/paas/v4", - "api": "openai-completions", - "apiKey": "$ZAI_API_KEY", - "models": [ - { - "id": "glm-5.3", - "name": "GLM-5.3", - "reasoning": true, - "contextWindow": 200000, - "maxTokens": 131072 - } - ] - }, - "local-qwen": { - "name": "Local Qwen", - "baseUrl": "https://ml-aritmolab.policlinicosandonato.it/v1", - "api": "openai-completions", - "apiKey": "local", - "models": [ - { - "id": "qwen3.6-35b-a3b", - "name": "Qwen3.6 35B A3B", - "reasoning": false, - "input": ["text"], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 131072, - "maxTokens": 16384, - "compat": { - "supportsDeveloperRole": false, - "supportsReasoningEffort": false, - "supportsStore": false, - "maxTokensField": "max_tokens" - } - } - ] - } - } -} diff --git a/deploy/pi/settings.json b/deploy/pi/settings.json deleted file mode 100644 index 6693eed8..00000000 --- a/deploy/pi/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "defaultProjectTrust": "always", - "enabledModels": [ - "zai/glm-5.3", - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", - "local-qwen/qwen3.6-35b-a3b" - ] -} diff --git a/deploy/psd/operator.env.example b/deploy/psd/operator.env.example index 9be923ba..7d78d414 100644 --- a/deploy/psd/operator.env.example +++ b/deploy/psd/operator.env.example @@ -14,9 +14,7 @@ THT_AUTH_CONFIG_ROOT=/deploy/psd/auth # DWH and Evidence credentials are entered later in Workspace management and stored encrypted # by the backend. They do not depend on host filesystem paths. -# Pi (LLM) -PI_PROVIDER=zai -PI_MODEL=glm-5.3 +# Pi runtime preference; provider/model defaults live only in installation modelCatalog. PI_THINKING=medium # App defaults diff --git a/deploy/psd/thothii-installation.yaml.example b/deploy/psd/thothii-installation.yaml.example index 4025732f..f373dd48 100644 --- a/deploy/psd/thothii-installation.yaml.example +++ b/deploy/psd/thothii-installation.yaml.example @@ -1,6 +1,6 @@ -# Esempio soltanto: `tht setup` genera deploy//thothii-installation.yaml. -# Sostituisci i path assoluti se usi questo riferimento per una configurazione avanzata. -# Seleziona UN solo override Git (https o ssh). +# Example only: `tht setup` generates deploy//thothii-installation.yaml. +# Replace every absolute path before using this as an advanced reference. +schemaVersion: 2 profile: local projectDirectory: "/projects/ThothII" envFile: "/projects/ThothII/deploy/psd/operator.env" @@ -8,38 +8,67 @@ workspaceRepository: remote: git@github.com:mptyl/tht-workspace-psd.git branch: main access: ssh -metadataGeneration: - default: glm-53 - models: - - id: deepseek-v4-pro - label: DeepSeek V4 Pro - litellm: - provider: deepseek - model: deepseek-v4-pro - apiKeyEnv: DEEPSEEK_API_KEY - - id: deepseek-v4-flash - label: DeepSeek V4 Flash - litellm: - provider: deepseek - model: deepseek-v4-flash - apiKeyEnv: DEEPSEEK_API_KEY - - id: glm-53 - label: GLM 5.3 - litellm: - provider: openai - model: glm-5.3 - endpoint: - baseUrl: https://api.z.ai/api/coding/paas/v4 - apiKeyEnv: ZAI_API_KEY - - id: qwen-36 - label: AritmoLab Qwen 3.6 35B A3B - litellm: - provider: openai - model: qwen3.6-35b-a3b - disableThinking: true - endpoint: - baseUrl: https://ml-aritmolab.policlinicosandonato.it/v1 -# Qwen omette apiKeyEnv: l'endpoint VPN non autentica le richieste. +modelCatalog: + defaults: + session: zai/glm-5.3 + metadataGeneration: zai/glm-5.3 + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 + providers: + deepseek: + authentication: + mode: pi_auth + session: + mode: pi_builtin + models: + deepseek-v4-pro: + session: {} + deepseek-v4-flash: + session: {} + zai: + endpoint: + baseUrl: https://api.z.ai/api/coding/paas/v4 + authentication: + mode: secret_env + apiKeyEnv: ZAI_API_KEY + session: + mode: openai_compatible + metadataGeneration: + litellmProvider: openai + models: + glm-5.3: + label: GLM 5.3 + session: + reasoning: true + contextWindow: 200000 + maxTokens: 131072 + metadataGeneration: {} + local-qwen: + endpoint: + baseUrl: https://ml-aritmolab.policlinicosandonato.it/v1 + authentication: + mode: none + session: + mode: openai_compatible + metadataGeneration: + litellmProvider: openai + models: + qwen3.6-35b-a3b: + label: AritmoLab Qwen 3.6 35B A3B + session: + reasoning: false + input: [text] + cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0} + contextWindow: 131072 + maxTokens: 16384 + compatibility: + supportsDeveloperRole: false + supportsReasoningEffort: false + supportsStore: false + maxTokensField: max_tokens + metadataGeneration: + disableThinking: true authentication: configDirectory: "/projects/ThothII/deploy/psd/auth" overrides: diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 232119a8..88fab80f 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -10,8 +10,9 @@ chmod 600 deploy/secrets/thothii.secrets The file uses strict `KEY=VALUE` lines (comments and blank lines are allowed). The supported installation keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, `THT_SSL_CA`, -`THT_OIDC_CLIENT_SECRET`, and `THT_AUTHENTIK_API_TOKEN`. Metadata-generation models may reference -exactly one of `THT_METADATA_API_KEY`, `ANTHROPIC_API_KEY`, `AZURE_API_KEY`, `GEMINI_API_KEY`, +`THT_OIDC_CLIENT_SECRET`, and `THT_AUTHENTIK_API_TOKEN`. Installation Model Catalog providers may +reference exactly one of `THT_MODEL_API_KEY`, `THT_METADATA_API_KEY`, `ANTHROPIC_API_KEY`, +`AZURE_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, or `ZAI_API_KEY` through their descriptor `apiKeyEnv`. An entry for an explicitly configured endpoint that accepts unauthenticated requests may omit `apiKeyEnv`; hosted/default endpoints must always reference a key. @@ -23,10 +24,10 @@ installation. Other configured values must be non-empty and contain no whitespace. Do not put secrets in the root `.env`, installation YAML, workspace YAML, URLs, logs, or rendered Compose output. -`THT_MODEL_API_KEY` remains the generic Pi child credential. Metadata generation is a separate -backend-owned runtime and reads only the key named by its own `metadataGeneration.models[].apiKeyEnv` -(when present); -it does not read Pi settings, `PI_AUTH_FILE`, or workspace `llm_policy`. +Session and metadata-generation runtimes read only the provider key named by +`modelCatalog.providers..authentication.apiKeyEnv`. They share the declaration and +credential reference, not their execution lifecycle. Pi-owned authentication remains available only +to session-only built-in providers through `authentication.mode: pi_auth`. Do not add vector or embedding endpoint credentials to the bundle. Active operator manuals use internal Qdrant and Ollama services, so vector/embedding runtime endpoint secrets are not part of @@ -56,7 +57,7 @@ and only then deleting the old files. The old variables remain a compatibility p upgrades, but the documented and tested default is an absolute `THT_SECRETS_FILE` path to the protected bundle. -Hosted Pi providers must use a single model key through `THT_MODEL_API_KEY`. Compound providers +Hosted providers must use one explicitly named catalog key. Compound providers (Bedrock, Azure OpenAI Responses, Cloudflare Workers AI/Gateway) fail closed until a provider-specific credential adapter is implemented. diff --git a/deploy/secrets/thothii.secrets.example b/deploy/secrets/thothii.secrets.example index 9d2af300..760af8c5 100644 --- a/deploy/secrets/thothii.secrets.example +++ b/deploy/secrets/thothii.secrets.example @@ -2,14 +2,13 @@ # Values are read as literal strings (no shell expansion or command substitution). # Leave unused keys out of the file. -# Hosted model provider (single-key providers only). -# THT_MODEL_API_KEY=replace-me - -# Description Generation only. The selected name must match apiKeyEnv in metadataGeneration. -# Allowed names: THT_METADATA_API_KEY, ANTHROPIC_API_KEY, AZURE_API_KEY, GEMINI_API_KEY, +# Hosted catalog provider (single-key providers only). The selected name must match +# modelCatalog.providers..authentication.apiKeyEnv. +# Allowed names include THT_MODEL_API_KEY, THT_METADATA_API_KEY, ANTHROPIC_API_KEY, +# AZURE_API_KEY, GEMINI_API_KEY, # DEEPSEEK_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or ZAI_API_KEY. # OPENAI_API_KEY=replace-me -# A model with an explicit unauthenticated endpoint omits apiKeyEnv and needs no bundle entry. +# A provider with an explicit keyless endpoint uses authentication.mode: none. # External DWH adapter. # THT_DWH_API_KEY=replace-me diff --git a/deploy/workspaces/example.yaml b/deploy/workspaces/example.yaml index c625015f..ffe97f3c 100644 --- a/deploy/workspaces/example.yaml +++ b/deploy/workspaces/example.yaml @@ -1,8 +1,8 @@ workspace: - schema_version: 3 + schema_version: 4 id: example name: Example workspace - description: Generic example WorkspaceV3 descriptor. + description: Generic example WorkspaceV4 descriptor. language: en dwh: @@ -13,17 +13,6 @@ dwh: - postgres_direct - rest_api -semantic_index: - vector_store: - engine: qdrant - collection: example - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 - evidence: source: type: filesystem @@ -35,11 +24,6 @@ evidence: max_chunk_chars: 4000 retain_published_generations: 3 -llm_policy: - default: zai/glm-5.2 - allowed: - - zai/glm-5.2 - diagnostics: dwh_rest: method: GET diff --git a/deploy/workspaces/psd.yaml.example b/deploy/workspaces/psd.yaml.example index 39eea0d0..fcdcdf73 100644 --- a/deploy/workspaces/psd.yaml.example +++ b/deploy/workspaces/psd.yaml.example @@ -1,8 +1,8 @@ workspace: - schema_version: 3 + schema_version: 4 id: example-workspace name: Example Workspace - description: Example WorkspaceV3 descriptor. + description: Example WorkspaceV4 descriptor. language: en dwh: @@ -13,17 +13,6 @@ dwh: - postgres_direct - rest_api -semantic_index: - vector_store: - engine: qdrant - collection: example-workspace - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 - evidence: source: type: filesystem @@ -35,11 +24,6 @@ evidence: max_chunk_chars: 4000 retain_published_generations: 3 -llm_policy: - default: zai/glm-5.2 - allowed: - - zai/glm-5.2 - diagnostics: dwh_rest: method: GET diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4cc28918..e7aa48db 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,6 +1,5 @@ -# ThothII standalone development/smoke stack. -# Run with the canonical local env file: -# docker compose --env-file deploy/env/local.env -f docker-compose.dev.yml up -d --build +# ThothII standalone development/smoke base. Model settings are intentionally absent; +# use `tht start --build`, which adds the generated modelCatalog projection. # frontend: http://localhost:8090 backend: http://localhost:8787 name: thothii-dev @@ -36,8 +35,6 @@ services: THT_LLM_URL: ${THT_LLM_URL:-} THT_INTERNAL_QDRANT_URL: http://qdrant:6333 THT_INTERNAL_EMBEDDING_URL: http://embedding:11434 - THT_INTERNAL_EMBEDDING_MODEL: qwen3-embedding:0.6b - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024" MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4} extra_hosts: - "host.docker.internal:host-gateway" @@ -45,8 +42,6 @@ services: - dev-data:/data - dev-pi-state:/home/thoth/.pi - ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro - - ./deploy/pi/models.json:/home/thoth/.pi/agent/models.json:ro - - ./deploy/pi/settings.json:/home/thoth/.pi/agent/settings.json:ro - workspace-registry:/data/workspace-registry - workspace-secrets:/data/workspace-secrets - ${THT_DEV_EVIDENCE_HOST_PATH:-./evidence}:/data/evidence:ro @@ -129,7 +124,6 @@ services: entrypoint: ["/usr/bin/bash", "/opt/thoth/embedding-model-init.sh"] environment: OLLAMA_BASE_URL: http://embedding:11434 - OLLAMA_MODEL: qwen3-embedding:0.6b OLLAMA_WAIT_TIMEOUT_SEC: "180" volumes: - embedding-models:/root/.ollama diff --git a/docker/embedding-model-init.sh b/docker/embedding-model-init.sh index 312794f1..73d745ac 100755 --- a/docker/embedding-model-init.sh +++ b/docker/embedding-model-init.sh @@ -2,7 +2,7 @@ set -euo pipefail ollama_base_url=${OLLAMA_BASE_URL:-http://embedding:11434} -ollama_model=${OLLAMA_MODEL:-qwen3-embedding:0.6b} +ollama_model=${OLLAMA_MODEL:?OLLAMA_MODEL must be projected from installation modelCatalog} wait_timeout_sec=${OLLAMA_WAIT_TIMEOUT_SEC:-180} case "$wait_timeout_sec" in diff --git a/docs/architecture/components.md b/docs/architecture/components.md index ae25112a..45493704 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -20,11 +20,12 @@ flowchart LR THT --> FS["Sessions and artifacts\nworkspace repository"] THT --> DWH["DWH\nread-only"] THT --> VDB["Qdrant / vector store"] - BE --> CFG["settings.json\nworkspace registry"] + BE --> CFG["settings.json\nworkspace + thinking"] + BE --> MODELS["generated runtime catalog\nfrom installation YAML"] BE --> CAT["catalog-db\nPostgreSQL + Kysely"] BE -->|catalog Test + Sync + bounded AI sampling| DWH BE -->|one request per subprocess| LLMHELPER["LiteLLM helper\nPython, short-lived"] - LLMHELPER -->|configured model| PROVIDER["AI provider"] + LLMHELPER -->|catalog-selected model| PROVIDER["AI provider"] FE -.->|renders widgets| EXT ``` diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e30c76f6..ab123a8a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -58,8 +58,9 @@ A session is a directory under `sessions/` (the workspace defines the path): `se the configured DWH connection; `ProcessModelCompleter` invokes the short-lived Python LiteLLM helper with the installation-selected model. -Application settings remain in `backend/data/settings.json`; session state remains in harness phase -documents. PostgreSQL stores only the administrative database catalog, bindings, observed tables, +Application settings keep only workspace and thinking preferences in `backend/data/settings.json`; +provider/model defaults come from the generated Installation Model Catalog. Session state remains in +harness phase documents. PostgreSQL stores only the administrative database catalog, bindings, observed tables, curated and generated descriptions, description-generation runs, and sanitized run events. Connector secrets remain write-only in the encrypted workspace secret store; model credentials remain in the protected installation secret bundle. Catalog SSH support is limited to connection @@ -97,7 +98,8 @@ operations that perform this upgrade. - `--json` output must be plain JSON on stdout. It is a machine-readable contract. - UI strings are in English. Document *content* stays in the workspace language because it is the actual data; only chrome and labels are in English. - Each workspace defines its DWH target and working directories. Secrets remain in protected installation files, not in the workspace repository. -- Settings are global (`backend/data/settings.json`: workspace/provider/model/thinking); the new-session form asks only for the question. +- Settings are global (`backend/data/settings.json`: workspace/thinking); provider/model choices are + canonical catalog references selected per session. - **Resume**: a resumable session returns to its last incomplete phase. The backend rejects resume with 409 when `finalized` or `archived`; `PiProcessManager.spawnFor` must send `/riprendi-sessione ` for resume and `/nuova-domanda` for a new session. The wrong prompt silently turns a resume into a new question. ## Runtime composition diff --git a/docs/contracts/workspace-evidence-v3.md b/docs/contracts/workspace-evidence-v3.md index 0e20e9cb..6d04cd50 100644 --- a/docs/contracts/workspace-evidence-v3.md +++ b/docs/contracts/workspace-evidence-v3.md @@ -1,7 +1,7 @@ -# Workspace Evidence v3 contract +# Workspace Evidence contract -This is the canonical public contract for the optional `evidence` object in a schema-v3 -workspace descriptor. Evidence is optional: a valid v3 descriptor without it remains operational. +This is the canonical public contract for the optional `evidence` object in a schema-v4 +workspace descriptor. Evidence is optional: a valid v4 descriptor without it remains operational. When present, `evidence` is strict: it contains `source` and a defaulted strict `policy`; every source variant and the policy reject unknown keys. diff --git a/docs/general/pi-configuration.md b/docs/general/pi-configuration.md index e2ba854f..2819d965 100644 --- a/docs/general/pi-configuration.md +++ b/docs/general/pi-configuration.md @@ -1,207 +1,158 @@ -# Local Pi model configuration +# Installation Model Catalog -Pi orchestrates the NL-to-SQL workflow and resolves built-in models and OpenAI-compatible providers -declared in the local catalog. ThothII applies a stricter rule than Pi: code in -`harness/.pi/extensions/` cannot register a provider or custom model. Endpoints, protocols, -compatibility settings, and model identifiers belong exclusively in the local files -`deploy/pi/models.json` and `deploy/pi/settings.json`. +ThothII has one operator-authored model source: `modelCatalog` in +`deploy//thothii-installation.yaml`. It declares models used by interactive Pi +sessions, metadata generation, and the internal embedding service. Workspace descriptors never +declare providers, model allowlists, defaults, embeddings, dimensions, or vector-store settings. -> **ThothII operator note:** ThothII runs Pi only in Docker Compose. Paths under -> `~/.pi/agent/` in this document describe Pi's container-side behavior. Operators edit -> `deploy/pi/models.json` and `deploy/pi/settings.json` in the ThothII project root and use -> the protected host credential file selected by `PI_AUTH_FILE`; they do not edit files inside -> the running container. +Do not edit `deploy/pi/models.json`, `deploy/pi/settings.json`, files under `generated/`, or +provider/model environment defaults. Those former sources are retired. -## Credentials in the backend container +## Minimal catalog -In production, configure one generic source: the `THT_SECRETS_FILE` bundle with the -`THT_MODEL_API_KEY` entry, which is the default for the unified distribution, or -`THT_MODEL_API_KEY_FILE` as an absolute secret-file path. Do not put the key value in `.env`. -For each process, `PiProcessManager` rereads and validates the source, normalizes the selected -provider, and passes only the appropriate native variable to the Pi child -(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, and so on). It removes the -generic path, keys for unselected providers, and the old `PI_PROVIDER_API_KEY` from the child -environment. For a custom provider, the backend derives the variable name from the declarative -`apiKey` field in `models.json`; a literal value means the catalog is self-contained. Provider-name -exceptions are not compiled into the code. A missing or insecure secret fails before spawn with a -sanitized error. +```yaml +schemaVersion: 2 +modelCatalog: + defaults: + session: zai/glm-5.3 + metadataGeneration: zai/glm-5.3 -The generic source supports only single-key providers: `ant-ling`, `anthropic`, -`cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google` (including the `gemini` alias), -`google-vertex` in API-key mode, `groq`, `huggingface`, `kimi-coding`, `minimax`, `minimax-cn`, -`mistral`, `moonshotai`, `moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`, -`openrouter`, `together`, `vercel-ai-gateway`, `xai`, the four `xiaomi*` providers, `zai`, and -`zai-coding-cn`. + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 -The composite providers `amazon-bedrock`, `azure-openai-responses`, `cloudflare-workers-ai`, and -`cloudflare-ai-gateway` cannot be represented by one file. Selection fails before spawn, including -when models are listed. AWS, Azure, and Cloudflare environment credentials are still removed. A -future provider-specific configuration will be needed to support these bundles unambiguously. - -## Model sources - -### 1. Built-in (compiled into Pi) - -Pi ships with a list of known models (`models.generated.js` in the `@earendil-works/pi-ai` package): -Anthropic, OpenAI, Google, and third-party providers with well-known public APIs such as DeepSeek. -These require **no configuration** beyond credentials, provided through an environment variable or -`pi auth`. - -`deepseek/deepseek-v4-pro` is one of these models. It is included in Pi because `api.deepseek.com` -is a documented public API, not an internal endpoint. - -### 2. User-level `models.json` (`~/.pi/agent/models.json`) - -For an **OpenAI-compatible** endpoint that is not built in but needs no special transport logic, -declare it with its baseUrl, apiKey, and model list. This file exists **only at user level**; there is -no project-level equivalent, and `./.pi/models.json` is not read. - -In ThothII-managed installations, the file must be entirely declarative. ThothII recursively -rejects any JSON value that starts with `!`, including values inside `headers`, `models`, -`modelOverrides`, `compat`, arrays, or fields it does not yet know. Pi 0.80.3 would treat that -prefix as a shell command when making a request, so managed model catalogs do not allow it. The -returned error is fixed and contains no command, path, or secret. - -For secrets, use an environment reference such as `"$ZAI_API_KEY"` or `"${ZAI_API_KEY}"`. The -backend can populate the native variable for the selected provider by reading -`THT_MODEL_API_KEY_FILE`, or from the `THT_SECRETS_FILE` bundle (`THT_MODEL_API_KEY`). Credentials -can also come from the protected file mounted through `PI_AUTH_FILE`, with `apiKey` omitted from -`models.json`. There is no direct secret-file reference syntax in `models.json`. With `THT_MODEL_*` -sources, ThothII reads the file and turns it into the Pi child's environment variable; `PI_AUTH_FILE` -is mounted instead as a protected Pi credential store. For a literal leading exclamation mark, Pi's -declarative syntax is `$!`, not `!`. - -Real example in use on this machine: GLM (provider `zai`): - -```json -{ - "providers": { - "zai": { - "baseUrl": "https://api.z.ai/api/coding/paas/v4", - "api": "openai-completions", - "apiKey": "$ZAI_API_KEY", - "models": [ - { - "id": "glm-5.2", - "name": "GLM-5.2", - "reasoning": true, - "contextWindow": 200000, - "maxTokens": 131072 - } - ] - } - } -} + providers: + zai: + endpoint: + baseUrl: https://api.z.ai/api/coding/paas/v4 + authentication: + mode: secret_env + apiKeyEnv: ZAI_API_KEY + session: + mode: openai_compatible + metadataGeneration: + litellmProvider: openai + models: + glm-5.3: + label: GLM 5.3 + session: + reasoning: true + contextWindow: 200000 + maxTokens: 131072 + metadataGeneration: {} ``` -Because it is user-level, GLM is visible to **every project**. +Provider and model entries are maps. The keys form the canonical identity +`/`; `label` is only display text. A model is eligible for a use only when +it contains that use block: -### Extension providers: not allowed in ThothII +- `session` makes it selectable for interactive sessions; +- `metadataGeneration` makes it selectable for description generation; +- `embedding` is a single installation-level model rather than a selectable list. -Pi technically supports providers registered by JavaScript extensions, but ThothII does not use -that capability. Project extensions are reserved for the workflow and gates; they must not contain -`registerProvider(...)`. An endpoint that cannot be described by the OpenAI-compatible catalog is -not supported by this installation until the declarative contract is extended generically. +`defaults.session` is required. `defaults.metadataGeneration` is required exactly when at least +one metadata-generation model exists. A session manifest pins its canonical identity, so removing a +model never silently changes an existing session: resume fails with `model_unavailable`. -## Summary table (current machine state) +## Session adapters -| Model | Level | Why | Visibility | -|---|---|---|---| -| `deepseek/deepseek-v4-pro` | Built-in Pi | Known public API, already in the build | All projects | -| `deepseek/deepseek-v4-flash` | Built-in Pi | Known public API, already in the build | All projects | -| `zai/glm-5.3` | `deploy/pi/models.json` | Custom OpenAI-compatible endpoint | ThothII installation | -| `local-qwen/qwen3.6-35b-a3b` | `deploy/pi/models.json` | Locally configured OpenAI-compatible endpoint | ThothII installation | +Use `pi_builtin` for a model whose technical definition ships with Pi: -## Choosing the right level for a new model - -1. **Is the endpoint a public API already known to Pi?** Enable the exact identifier in `deploy/pi/settings.json`. -2. **Is it OpenAI-compatible but not built in?** Declare it in `deploy/pi/models.json`, then enable it in `deploy/pi/settings.json`. -3. **Does it require provider-specific transport code?** Do not add a provider-specific extension. The provider is unsupported until a generic declarative capability exists. - ---- - -## User versus project scope: general summary - -In addition to models, Pi loads other resources from two parallel trees: `~/.pi/agent/` (user) and -`/.pi/` (project, resolved from the directory where `pi` is launched). - -| File/Directory | Livello utente | Livello progetto | Auto-discovery | Precedenza | -|---|---|---|---|---| -| `models.json` | `~/.pi/agent/models.json` | not supported | no | user only | -| `settings.json` | `~/.pi/agent/settings.json` | `./.pi/settings.json` | no | project overrides user | -| `extensions/` | `~/.pi/agent/extensions/` | `./.pi/extensions/` | yes (`.ts`/`.js`) | merged (project + user) | -| `prompts/` | `~/.pi/agent/prompts/` | `./.pi/prompts/` | yes (`.md`) | merged | -| `themes/` | `~/.pi/agent/themes/` | `./.pi/themes/` | yes (`.json`) | project preferred | -| `skills/` | `~/.pi/agent/skills/` | `./.pi/skills/` | yes (`.md`) | merged | - -### Real example: ThothII - -``` -harness/.pi/ -├── extensions/ -│ ├── tht-gate.js # human-in-the-loop gate -│ └── gate/ -│ ├── core/ # shared gate enforcement and utilities -│ ├── disambiguation/ # F1/F3 policy -│ └── memory/ # F2/F8 policy -├── settings.json # optional override of user settings -└── themes/ - └── thothii-mono.json # project theme +```yaml +deepseek: + authentication: + mode: pi_auth + session: + mode: pi_builtin + models: + deepseek-v4-pro: + session: {} ``` -### Behavior relative to cwd +Use `openai_compatible` for an explicit compatible endpoint. Each eligible session model must then +declare the technical limits Pi needs. `upstreamModel` is optional and is used only when the +endpoint expects a model name different from the catalog key. -The directory from which you launch `pi` determines which workflow extensions are found, but not -which models ThothII makes available. The catalog is mounted in the container's Pi agent directory. +Provider integrations remain declarative. Do not register providers from +`harness/.pi/extensions/`; those extensions implement the workflow and human gates only. + +## Authentication + +Every provider chooses one explicit mode: + +- `secret_env` names an approved key in the protected ThothII secret bundle through `apiKeyEnv`; +- `pi_auth` uses Pi's protected authentication projection and is valid only for session-only + `pi_builtin` providers; +- `none` is valid only with an explicit keyless endpoint. + +Secret values never belong in installation YAML, generated files, logs, CLI arguments, or browser +requests. The YAML contains only an environment-variable name or an authentication mode. Pi's +protected credential file remains selected by the installation authentication configuration. + +## Generated runtime projections + +Before Compose starts, `tht` validates the installation and atomically writes deterministic files +under `deploy//generated/`: + +```text +generated/ +├── catalog.json +├── pi/ +│ ├── models.json +│ └── settings.json +└── compose.model-catalog.yaml +``` + +The normalized catalog is consumed by the backend. The Pi files and Compose override are boundary +adapters. They are not configuration sources and are excluded from backup. Restore regenerates +them from the installation descriptor. + +Run all lifecycle commands from the project root and select the descriptor explicitly when more +than one installation exists: ```bash -# From harness/: load the project gate and mounted local catalog -cd /path/to/ThothII/harness -pi --model local-qwen/qwen3.6-35b-a3b "..." +INSTALLATION=/absolute/path/deploy/example/thothii-installation.yaml + +tht --installation "$INSTALLATION" start +tht --installation "$INSTALLATION" doctor ``` -The ThothII backend always launches Pi with `cwd: harnessDir` for the workflow. Model availability -continues to depend only on `models.json`, `settings.json`, and local credentials. +After editing `modelCatalog` or provider credentials, reload the current Pi image. Restart validates +the YAML and regenerates projections before recreating `core`: ---- - -## Auto-discovery trap: `.mjs` is ignored - -Pi's extension auto-discovery pattern is **`/\.(ts|js)$/`**; it does not include `.mjs`. - -``` -.pi/extensions/ -├── my-extension.js ✅ auto-loaded -├── my-extension.ts ✅ auto-loaded -├── my-extension.mjs ❌ silently ignored (does not match the pattern) -└── shared-module.mjs ✅ suitable for helper modules (deliberately not loaded as an extension) -``` - -If an extension imports a shared module, use `.mjs` so Pi does not treat it as an extension on its own. - ---- - -## Verifica - -### List models ```bash -pi --list-models +tht --installation "$INSTALLATION" pi restart --yes --drain +tht --installation "$INSTALLATION" pi doctor +tht --installation "$INSTALLATION" pi test ``` -This shows built-in models and models declared in `models.json`. -### Check the catalog used by the application +`tht pi update` changes the Pi version; it is not the configuration command. There is no +`tht pi configure` and no separate apply command. + +## Migrating a legacy installation + +The migrator reads the former installation `metadataGeneration` block and the two former Pi JSON +files, but never modifies them. Supply the facts that cannot be inferred safely and write a separate +candidate: + ```bash -cd harness # or the project's relevant cwd -pi --mode rpc -# poi: {"type": "get_available_models", "id": "1"} +tht --installation /absolute/path/legacy-installation.yaml installation migrate \ + --output /absolute/path/thothii-installation.v2.yaml \ + --session-default zai/glm-5.3 \ + --embedding-id ollama/qwen3-embedding:0.6b \ + --embedding-dimensions 1024 ``` -The RPC response must include only built-in models or models declared in the local catalog. ---- +Review the candidate, move the legacy source files out of the installation only after approval, +then select the v2 descriptor. Ambiguous aliases, endpoint conflicts, or missing authentication +facts produce field-level errors; the migrator does not guess. ## Troubleshooting -| Problem | Cause | Solution | -|---|---|---| -| `Model "X/Y" not found` | Provider/model missing from `models.json` or identifier missing from `enabledModels` | Fix the two local files and reload Pi | -| Project settings not applied | Project `settings.json` has a syntax error, or `pi` is launched from the wrong cwd | Validate the JSON and check the cwd | +| Symptom | Meaning | Action | +| --- | --- | --- | +| `migration_required` | A retired model source or installation schema is still present | Run the installation migrator and review its candidate | +| Unknown session or metadata default | The canonical ID is missing the corresponding use block | Correct the provider/model key or add the intended use block | +| Generated projection drift | Runtime files differ from the descriptor-derived bytes | Run `tht start` or `tht pi restart --yes --drain` | +| `model_unavailable` on resume | The session's pinned model is no longer session-eligible | Restore that catalog entry or keep the session unavailable; do not remap it | +| Provider smoke failure | Credentials, endpoint, or provider availability is invalid | Correct the protected credential or catalog endpoint, restart, then run `tht pi test` | diff --git a/docs/install/examples/thothii-installation.local.yaml b/docs/install/examples/thothii-installation.local.yaml index 0f7f54b5..86e4177d 100644 --- a/docs/install/examples/thothii-installation.local.yaml +++ b/docs/install/examples/thothii-installation.local.yaml @@ -1,6 +1,7 @@ # Copy this file to a protected operator-controlled path named exactly thothii-installation.yaml # and set mode 0600 (or 0400) before using it as THT_INSTALLATION_CONFIG_SOURCE. # Replace every absolute placeholder. Select exactly one Git transport override. +schemaVersion: 2 profile: local projectDirectory: "/absolute/path/to/ThothII" envFile: "/absolute/path/to/ThothII/deploy/env/local.env" @@ -8,17 +9,27 @@ workspaceRepository: remote: git@git.example.com:organization/workspaces.git branch: main access: ssh -metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: - provider: openai - model: gpt-4.1-mini - apiKeyEnv: OPENAI_API_KEY - # apiKeyEnv may be omitted only for an explicit endpoint that accepts - # unauthenticated requests. +modelCatalog: + defaults: + session: deepseek/deepseek-v4-pro + metadataGeneration: openai/gpt-4.1-mini + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 + providers: + deepseek: + authentication: {mode: pi_auth} + session: {mode: pi_builtin} + models: + deepseek-v4-pro: + session: {} + openai: + authentication: {mode: secret_env, apiKeyEnv: OPENAI_API_KEY} + metadataGeneration: {litellmProvider: openai} + models: + gpt-4.1-mini: + label: OpenAI Mini + metadataGeneration: {} authentication: configDirectory: "/absolute/path/to/thothii-auth" overrides: diff --git a/docs/install/examples/thothii-installation.server.yaml b/docs/install/examples/thothii-installation.server.yaml index ec020b05..5cba44c2 100644 --- a/docs/install/examples/thothii-installation.server.yaml +++ b/docs/install/examples/thothii-installation.server.yaml @@ -1,6 +1,7 @@ # Copy this file to a protected operator path named exactly thothii-installation.yaml # and set mode 0600 (or 0400) before using it as THT_INSTALLATION_CONFIG_SOURCE. # Replace every absolute placeholder. Select exactly one Git transport override. +schemaVersion: 2 profile: server projectDirectory: "/absolute/path/to/ThothII" envFile: "/absolute/path/to/thothii-server-operator/server.env" @@ -8,19 +9,28 @@ workspaceRepository: remote: git@git.example.com:organization/workspaces.git branch: main access: ssh -metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: - provider: openai - model: gpt-4.1-mini - endpoint: - baseUrl: https://api.openai.example/v1 - apiKeyEnv: OPENAI_API_KEY - # apiKeyEnv may be omitted only for an explicit endpoint that accepts - # unauthenticated requests. +modelCatalog: + defaults: + session: deepseek/deepseek-v4-pro + metadataGeneration: openai/gpt-4.1-mini + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 + providers: + deepseek: + authentication: {mode: pi_auth} + session: {mode: pi_builtin} + models: + deepseek-v4-pro: + session: {} + openai: + endpoint: {baseUrl: https://api.openai.example/v1} + authentication: {mode: secret_env, apiKeyEnv: OPENAI_API_KEY} + metadataGeneration: {litellmProvider: openai} + models: + gpt-4.1-mini: + label: OpenAI Mini + metadataGeneration: {} authentication: # Root-operated source of truth; it is never mounted into core. configDirectory: "/srv/example/thothii/auth-canonical" diff --git a/docs/installazione-docker-4-contesti.md b/docs/installazione-docker-4-contesti.md index b80a4c69..ccd8a8c1 100644 --- a/docs/installazione-docker-4-contesti.md +++ b/docs/installazione-docker-4-contesti.md @@ -80,8 +80,9 @@ THT_DWH_API_KEY=... OPENAI_API_KEY=... ``` -`THT_MODEL_API_KEY` remains Pi-only. A metadata-generation model instead references one audited -bundle name from `deploy/secrets/README.md` through `metadataGeneration.models[].apiKeyEnv`. +`THT_MODEL_API_KEY` is available only as an explicitly declared catalog bundle key. A +metadata-generation provider references one audited bundle name from `deploy/secrets/README.md` +through `modelCatalog.providers..authentication.apiKeyEnv`. `apiKeyEnv` may be omitted only for an explicit endpoint that accepts unauthenticated requests; hosted/default endpoints remain keyed. Provider/model/endpoint settings stay in the protected installation descriptor; raw keys do not. diff --git a/docs/operations/database-management.md b/docs/operations/database-management.md index 8b0fa90c..bfe78410 100644 --- a/docs/operations/database-management.md +++ b/docs/operations/database-management.md @@ -70,14 +70,63 @@ capability, malformed snapshot, or connector error applies no catalog changes. S ## Synchronize authoritative schema metadata -Start a synchronization from a database or a selected table set. The available scopes are tables, -columns, relationships, and all. One database can have only one active catalog operation at a -time; cleanup shares this exclusion. +Schema synchronization reads the external database and reconciles the installation-local catalog. +It never changes the source database. The available synchronization scopes are **tables**, +**columns**, **relationships**, and **all**, but the UI exposes them at different levels: + +| Location | Action | Effective scope | +| --- | --- | --- | +| Database view | **Synchronize tables** | All tables in the selected database | +| Database view | **Synchronize relationships** | All physical foreign-key relationships in the selected database | +| Database view | **Synchronize all** | Tables, columns, and physical relationships in the selected database | +| Tables view | **Synchronize database tables** | All tables in the selected database. Selecting a table enables the action, but does not narrow its scope. | +| Tables view | **Synchronize columns for selected tables** | Columns belonging to the selected tables | +| Columns view | **Synchronize columns for this table** | All columns belonging to the table currently open. Selecting at least one column enables the action, but does not narrow its scope to that column. | + +There is no database-level column action and no synchronization action for an individual column. +The selection requirement in the tables and columns views controls whether the action selector can +be used; it is not always the same as the synchronization target. The **Sync all** button in the +tables view is the direct shortcut for the full-database scope. + +Before starting any synchronization, run **Test connection**. Synchronization is rejected when +the binding is unreachable or its tested version is older than the current binding configuration. +Only one catalog operation can be active for a database at a time; explicit cleanup shares this +exclusion. + +### What a synchronization does + +Every run first creates a durable queued operation and reads a schema snapshot. The scan reports +these phases: + +1. Connect to the database. +2. Read tables. +3. Read columns and primary-key positions. +4. Read foreign-key relationships. +5. Calculate the planned catalog difference. +6. Apply only the requested scope. + +The scan currently reads the complete physical snapshot, including foreign keys, even when the +requested scope is only tables or columns. This is required by the introspection contract and is +why the log can mention foreign-key reading during a table synchronization. Reading those keys +does not by itself create or update catalog relationships: + +- **Synchronize tables** writes table membership and source comments. If a table disappears, its + catalog columns and physical relationships are removed through the table cascade. +- **Synchronize columns** writes column membership and structural attributes for all tables or for + the selected table subset. It does not write physical relationships. +- **Synchronize relationships** writes the physical relationships derived from the source foreign + keys. It does not create generated or manual logical relationships. +- **Synchronize all** applies all three scopes and marks the database schema version as fully + synchronized. The run scans first and publishes a durable operation. If it detects a destructive difference, it requires confirmation and re-scans before applying. You can cancel before apply; completed and failed runs remain in history. The live log is delivered over SSE with a polling fallback. +The synchronization history records the requested scope, progress phases, planned changes, +confirmation, result counts, and errors. Closing the history drawer does not cancel a running +operation; it can be reopened from the database synchronization history control. + Explicit cleanup is different from source synchronization: administrators can clear selected table/relationship or column/relationship catalog metadata without changing the external source, the connection binding, or secrets. Deleting a table cascades to its columns and relationships. diff --git a/docs/operations/workspaces.md b/docs/operations/workspaces.md index 76ebe5e0..40840002 100644 --- a/docs/operations/workspaces.md +++ b/docs/operations/workspaces.md @@ -16,13 +16,20 @@ The root catalog has `schema_version: 1` and an ordered list of workspace identi -Schema v3 is the only accepted workspace descriptor. Schema v1 and v2 workspace descriptors are -rejected before activation. Each catalog entry must have a matching descriptor at +Schema v4 is the only accepted workspace descriptor. Schema v1, v2, and v3 workspace descriptors +are rejected before activation. Each catalog entry must have a matching descriptor at `/workspace.yaml` in the same Git commit. The application validates a complete candidate revision and activates it atomically; invalid content leaves the preceding active revision in place. + +To convert a v3 descriptor before committing it, set `workspace.schema_version` to `4`, remove +`llm_policy`, and remove `semantic_index`. Database, Evidence, diagnostics, and binding data remain +unchanged. Validate the resulting v4 repository revision before activation; ThothII never rewrites +the curator-owned repository during pull. + + ## Operator sequence 1. Curate and push a complete repository revision. Do not put DWH passwords, API keys, private @@ -58,7 +65,7 @@ tht --installation "$INSTALLATION" workspace preprocess run \ The contract gives exact validation, exit code, and JSON rules in [Workspace preprocessing CLI](../contracts/workspace-preprocessing-cli.md). For Evidence source -forms and the schema-v3 descriptor contract, see +forms and the schema-v4 descriptor contract, see [Workspace Evidence v3](../contracts/workspace-evidence-v3.md). ## Transport and revision rules diff --git a/docs/plans/2026-09-02-installation-model-catalog.md b/docs/plans/2026-09-02-installation-model-catalog.md index 48508562..f0898616 100644 --- a/docs/plans/2026-09-02-installation-model-catalog.md +++ b/docs/plans/2026-09-02-installation-model-catalog.md @@ -1,6 +1,6 @@ # Installation Model Catalog -Status: accepted design; implementation not started. +Status: implemented on 2026-09-02. ## Outcome @@ -241,5 +241,6 @@ drafts and model filtering, examples, fixtures, and documentation. Existing sess readable and keep their pinned provider/model identity; only resume resolution changes to the new catalog. -This document authorizes design only. Software implementation begins only after a separate explicit -request. +Implementation completed after explicit approval. The installation schema, deterministic runtime +projections, migration path, model-free workspace schema v4, backend consumers, operator UI, +fixtures, and documentation now enforce this contract. diff --git a/docs/testing/evidence-lifecycle-test-plan.md b/docs/testing/evidence-lifecycle-test-plan.md index dcbc5e00..be841774 100644 --- a/docs/testing/evidence-lifecycle-test-plan.md +++ b/docs/testing/evidence-lifecycle-test-plan.md @@ -288,7 +288,7 @@ inferenza dalla UI. | PRE-03 | Verificare Pi, provider autore, Ollama, Qdrant, DWH e auth | tutti raggiungibili senza esporre credenziali | | PRE-04 | Inventariare `psd-clinical` come gruppo di controllo | commit, ACTIVE, counts per kind e canary `disambiguation` con expected ID salvati | | PRE-05 | Verificare assenza del workspace/collection lab | nessuna collisione; se esistono, fermarsi e identificare il proprietario | -| PRE-06 | Aggiungere catalog entry, descriptor lab e `evidence/README.md` tracciato in una revisione candidata | ID, URI e collection distinti; descriptor schema v3 valido; tree Evidence risolvibile anche senza D1-D3 | +| PRE-06 | Aggiungere catalog entry, descriptor lab e `evidence/README.md` tracciato in una revisione candidata | ID e URI distinti; descriptor workspace schema v4 valido; tree Evidence risolvibile anche senza D1-D3 | | PRE-07 | Verificare nel processo dell'installazione `THT_WORKSPACE_GIT_BRANCH=test/evidence-lifecycle`, pushare la revisione su quel branch e usare **Update workspace repository** nella UI | la UI mostra ref/commit attesi, candidate valida attivata atomicamente; PSD resta disponibile | | PRE-08 | Selezionare il lab, configurare binding/secret, **Validate workspace source** e **Test workspace connections** | check verdi; secret solo write-only | | PRE-09 | Selezionare il lab come workspace dell'installazione | nuove sessioni usano il lab; nessuna sessione PSD viene mutata | diff --git a/frontend/e2e/fixtures/auth-stack.mjs b/frontend/e2e/fixtures/auth-stack.mjs index 817f5a78..c643286c 100644 --- a/frontend/e2e/fixtures/auth-stack.mjs +++ b/frontend/e2e/fixtures/auth-stack.mjs @@ -74,7 +74,7 @@ function runFixtureCommand(command, args, cwd) { const F1_WORKSPACE_ID = "fixture-workspace"; const F1_WORKSPACE_DESCRIPTOR = `workspace: - schema_version: 3 + schema_version: 4 id: fixture-workspace name: Fixture workspace language: en @@ -83,19 +83,6 @@ dwh: database: fixture schema: fixture supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: fixture-workspace - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [zai/glm-5.2] - default: zai/glm-5.2 `; async function prepareF1Workspace(root) { diff --git a/frontend/src/api/pi-management.test.ts b/frontend/src/api/pi-management.test.ts index 52968706..2fd532cd 100644 --- a/frontend/src/api/pi-management.test.ts +++ b/frontend/src/api/pi-management.test.ts @@ -3,10 +3,8 @@ import { server } from "../test/msw"; import { asPiManagementApiError, getPiManagementLogs, - getPiManagementOptions, getPiManagementStatus, runPiManagementTest, - savePiManagementConfig, } from "./pi-management"; test("Pi management client calls only the sanctioned sanitized endpoints", async () => { @@ -16,14 +14,6 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async calls.push({ method: request.method, path: new URL(request.url).pathname }); return HttpResponse.json({ ready: true, version: "0.80.3", credentials: "present", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" }); }), - http.get("/api/pi-management/options", ({ request }) => { - calls.push({ method: request.method, path: new URL(request.url).pathname }); - return HttpResponse.json({ providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z" }); - }), - http.put("/api/pi-management/config", async ({ request }) => { - calls.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() }); - return HttpResponse.json({ provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z" }); - }), http.post("/api/pi-management/test", ({ request }) => { calls.push({ method: request.method, path: new URL(request.url).pathname }); return HttpResponse.json({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" }); @@ -35,15 +25,11 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async ); await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true, credentials: "present" }); - await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] }); - await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" }); await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true }); await expect(getPiManagementLogs()).resolves.toMatchObject({ lines: ["Pi smoke check succeeded"] }); expect(calls).toEqual([ { method: "GET", path: "/api/pi-management/status" }, - { method: "GET", path: "/api/pi-management/options" }, - { method: "PUT", path: "/api/pi-management/config", body: { provider: "zai", model: "glm-5.2", reasoning: "high" } }, { method: "POST", path: "/api/pi-management/test" }, { method: "GET", path: "/api/pi-management/logs" }, ]); diff --git a/frontend/src/api/pi-management.ts b/frontend/src/api/pi-management.ts index 2b12a801..b21a6b2d 100644 --- a/frontend/src/api/pi-management.ts +++ b/frontend/src/api/pi-management.ts @@ -17,13 +17,6 @@ export interface PiManagementStatus { message?: string; } -export interface PiManagementOptions { - providers: string[]; - models: Array<{ provider: string; id: string }>; - reasoning: PiReasoning[]; - checkedAt: string; -} - export interface PiManagementTestResult { ready: boolean; checkedAt: string; @@ -37,9 +30,7 @@ export interface PiManagementLogs { export type PiManagementApiErrorCode = | "pi_management_forbidden" - | "pi_management_unavailable" - | "pi_management_invalid_config" - | "pi_management_write_failed"; + | "pi_management_unavailable"; export interface PiManagementApiError { status: number; @@ -50,8 +41,6 @@ export interface PiManagementApiError { const errorCodes = new Set([ "pi_management_forbidden", "pi_management_unavailable", - "pi_management_invalid_config", - "pi_management_write_failed", ]); /** Narrows the sanctioned error code and derives its message locally. */ @@ -65,11 +54,6 @@ export function asPiManagementApiError(error: unknown): PiManagementApiError | u } export const getPiManagementStatus = () => apiFetch("/pi-management/status"); -export const getPiManagementOptions = () => apiFetch("/pi-management/options"); -export const savePiManagementConfig = (config: PiInstallationConfig) => - apiFetch("/pi-management/config", { - method: "PUT", body: JSON.stringify(config), - }); export const runPiManagementTest = () => apiFetch("/pi-management/test", { method: "POST" }); export const getPiManagementLogs = () => apiFetch("/pi-management/logs"); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 7284db61..4f6630a7 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -6,7 +6,7 @@ import { type AuthOperationPrecondition, } from "../auth/authOperation"; import { getSettings } from "./settings"; -import { getWorkspace, listWorkspaces } from "./workspaces"; +import { listWorkspaces } from "./workspaces"; import { WORKSPACE_POLICY_ERROR, WORKSPACE_SUMMARY_ERROR, WorkspaceSelectionError, workspacePolicyGate, workspacePreferences, type WorkspacePreference, @@ -34,24 +34,6 @@ async function selectedPreferences(precondition?: AuthOperationPrecondition): Pr }); } -function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: readonly string[], defaultModel?: string) { - if (allowed.length === 0) throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); - const selected = preferences.provider && preferences.model - ? `${preferences.provider}/${preferences.model}` - : undefined; - if (selected && allowed.includes(selected)) return preferences; - const replacement = defaultModel && allowed.includes(defaultModel) ? defaultModel : allowed[0]; - const separator = replacement.indexOf("/"); - if (separator <= 0 || separator === replacement.length - 1) { - throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); - } - return workspacePreferences.save({ - ...preferences, - provider: replacement.slice(0, separator), - model: replacement.slice(separator + 1), - }); -} - async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise { while (true) { requireAuthOperationPrecondition(precondition); @@ -104,45 +86,8 @@ async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecon throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); } workspacePolicyGate.select(workspaceId); - const outcome = await Promise.race([ - getWorkspace(workspaceId).then( - (record) => ({ kind: "record" as const, record }), - () => ({ kind: "error" as const }), - ), - workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()).then( - (selection) => ({ kind: "selection" as const, selection }), - ), - ]); - requireAuthOperationPrecondition(precondition); - if (outcome.kind === "selection") { - if (outcome.selection.workspaceId !== workspaceId) continue; - return outcome.selection; - } - if (outcome.kind === "error") { - requireAuthOperationPrecondition(precondition); - if (workspacePreferences.load().workspaceId !== workspaceId) continue; - workspacePolicyGate.reject(workspaceId); - throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR); - } - const { record } = outcome; - requireAuthOperationPrecondition(precondition); - if (workspacePreferences.load().workspaceId !== workspaceId) continue; - let selection: WorkspacePreference; - try { - selection = reconcileWorkspacePolicy( - preferences, - record.workspace.llm_policy.allowed, - record.workspace.llm_policy.default, - ); - } catch (error) { - requireAuthOperationPrecondition(precondition); - workspacePolicyGate.reject(workspaceId); - throw error; - } - requireAuthOperationPrecondition(precondition); workspacePolicyGate.resolve(workspaceId); - requireAuthOperationPrecondition(precondition); - if (workspacePreferences.load().workspaceId === workspaceId) return selection; + return workspacePreferences.load(); } } diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 682ccdff..ee3e88eb 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -62,7 +62,7 @@ export interface WorkspaceEvidence { export interface CanonicalWorkspace { workspace: { - schema_version: 3; + schema_version: 4; id: string; name: string; description?: string; @@ -76,20 +76,6 @@ export interface CanonicalWorkspace { timeout_ms?: number; supported_transports: ("postgres_direct" | "rest_api" | "ssh_tunnel")[]; }; - semantic_index: { - vector_store: { - engine: "qdrant"; - collection: string; - dimensions: 1024; - distance: "cosine"; - } - embedding: { - provider: "ollama_internal"; - model: "qwen3-embedding:0.6b"; - dimensions: 1024; - }; - }; - llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] }; diagnostics?: CanonicalDiagnostics; evidence?: WorkspaceEvidence; } diff --git a/frontend/src/shell/PiManagement.test.tsx b/frontend/src/shell/PiManagement.test.tsx index ca840170..a0b8811a 100644 --- a/frontend/src/shell/PiManagement.test.tsx +++ b/frontend/src/shell/PiManagement.test.tsx @@ -13,111 +13,42 @@ const readyStatus = { checkedAt: "2026-08-05T10:00:00.000Z", }; -const options = { - providers: ["zai", "deepseek"], - models: [ - { provider: "zai", id: "glm-5.2" }, - { provider: "deepseek", id: "deepseek-v4" }, - ], - reasoning: ["low", "medium", "high"], - checkedAt: "2026-08-05T10:00:00.000Z", -}; - function renderManagement() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( undefined} />); + return render( + + undefined} /> + , + ); } beforeEach(() => { - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json(readyStatus)), - http.get("/api/pi-management/options", () => HttpResponse.json(options)), - ); + server.use(http.get("/api/pi-management/status", () => HttpResponse.json(readyStatus))); }); -test("loads Pi version and readiness as an accessible operational rail", async () => { +test("shows the catalog default as read-only installation state", async () => { renderManagement(); expect(screen.getByText("Loading Pi management…")).toBeVisible(); expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); - await screen.findByLabelText("Provider"); const dialog = screen.getByRole("dialog", { name: "Pi management" }); expect(dialog).toHaveAttribute("aria-modal", "false"); expect(dialog).toHaveAttribute("data-work-area-panel"); - expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1); - expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1); + expect(await screen.findByText("Pi 0.80.3")).toBeVisible(); expect(screen.getByTestId("pi-readiness-rail")).toHaveTextContent("Runtime ready"); expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready"); - expect(screen.getByText("Pi 0.80.3")).toBeVisible(); expect(screen.getByText("Defaults ready")).toBeVisible(); - expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeVisible(); -}); - -test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => { - renderManagement(); - - const provider = await screen.findByLabelText("Provider"); - expect(provider).toHaveValue("zai"); - expect(screen.getByLabelText("Model")).toHaveValue("glm-5.2"); - expect(screen.getByLabelText("Reasoning level")).toHaveValue("medium"); - expect(within(provider).getAllByRole("option").map((option) => option.textContent)).toEqual(["zai", "deepseek"]); - expect(screen.getByLabelText("Model")).toHaveTextContent("GLM 5.2"); - expect(screen.queryByRole("textbox", { name: /provider|model|reasoning|credential/i })).not.toBeInTheDocument(); + const catalog = screen.getByRole("region", { name: "Installation catalog default" }); + expect(catalog).toHaveTextContent("zai"); + expect(catalog).toHaveTextContent("glm-5.2"); + expect(catalog).toHaveTextContent("medium"); + expect(screen.getByRole("button", { name: "Test catalog default" })).toBeVisible(); + expect(screen.queryByRole("button", { name: /save defaults/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: /provider|model|reasoning/i })).not.toBeInTheDocument(); expect(document.querySelector('input[type="password"]')).toBeNull(); - expect(screen.queryByRole("button", { name: /terminal|shell access/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Update Pi" })).not.toBeInTheDocument(); }); -test("saves only a selected non-secret configuration", async () => { - const user = userEvent.setup(); - let saved: unknown; - server.use(http.put("/api/pi-management/config", async ({ request }) => { - saved = await request.json(); - return HttpResponse.json({ ...saved as object, updatedAt: "2026-08-05T10:02:00.000Z" }); - })); - renderManagement(); - - await user.selectOptions(await screen.findByLabelText("Provider"), "deepseek"); - await user.selectOptions(screen.getByLabelText("Reasoning level"), "high"); - await user.click(screen.getByRole("button", { name: "Save defaults" })); - - await waitFor(() => expect(saved).toEqual({ provider: "deepseek", model: "deepseek-v4", reasoning: "high" })); - expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved"); -}); - -// Catches a provider switch updating only the saved config while leaving the credential rail -// attached to the previously selected provider. -test("shows authoritative credential presence after saving a different provider", async () => { - const user = userEvent.setup(); - let saved = false; - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json(saved ? { - ...readyStatus, - credentials: "missing", - config: { provider: "deepseek", model: "deepseek-v4", reasoning: "medium" }, - checkedAt: "2026-08-05T10:02:00.000Z", - } : readyStatus)), - http.put("/api/pi-management/config", async ({ request }) => { - saved = true; - return HttpResponse.json({ - ...await request.json() as object, - updatedAt: "2026-08-05T10:01:00.000Z", - }); - }), - ); - renderManagement(); - - expect(await screen.findByText("Credentials present")).toBeVisible(); - await user.selectOptions(screen.getByLabelText("Provider"), "deepseek"); - await user.click(screen.getByRole("button", { name: "Save defaults" })); - - expect(await screen.findByText("Credentials missing")).toBeVisible(); - expect(screen.queryByText("Credentials present")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Provider")).toHaveValue("deepseek"); - expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved"); -}); - -test("runs the saved-configuration test without changing credential presence", async () => { +test("tests the catalog default without mutating credential presence", async () => { const user = userEvent.setup(); let tests = 0; server.use(http.post("/api/pi-management/test", () => { @@ -128,204 +59,95 @@ test("runs the saved-configuration test without changing credential presence", a })); renderManagement(); - const testButton = await screen.findByRole("button", { name: "Test saved defaults" }); - expect(screen.getByText("Defaults ready")).toBeVisible(); - expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument(); - expect(testButton).toBeEnabled(); + const testButton = await screen.findByRole("button", { name: "Test catalog default" }); await user.click(testButton); - await waitFor(() => expect(tests).toBe(1)); - expect(await screen.findByText("Saved configuration test passed")).toBeVisible(); + expect(await screen.findByText("Catalog default test passed.")).toBeVisible(); expect(screen.getByText("Credentials present")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Test saved defaults" })); - expect(await screen.findByText("Saved configuration test failed")).toBeVisible(); + await user.click(testButton); + expect(await screen.findByText(/Catalog default test failed/)).toBeVisible(); expect(screen.getByText("Credentials present")).toBeVisible(); }); -test("fetches and displays bounded sanitized diagnostic logs only on request", async () => { +test("fetches bounded sanitized diagnostics only on request", async () => { const user = userEvent.setup(); let logRequests = 0; server.use(http.get("/api/pi-management/logs", () => { logRequests += 1; - return HttpResponse.json({ lines: ["Pi smoke check succeeded", "provider token=[REDACTED]"], checkedAt: "2026-08-05T10:04:00.000Z" }); + return HttpResponse.json({ + lines: ["Pi smoke check succeeded", "provider token=[REDACTED]"], + checkedAt: "2026-08-05T10:04:00.000Z", + }); })); renderManagement(); - await screen.findByLabelText("Provider"); + await screen.findByText("Pi 0.80.3"); expect(logRequests).toBe(0); await user.click(screen.getByRole("button", { name: "Show sanitized logs" })); expect(await screen.findByLabelText("Sanitized Pi diagnostics")).toHaveTextContent("provider token=[REDACTED]"); expect(logRequests).toBe(1); - expect(screen.queryByText("raw-provider-token")).not.toBeInTheDocument(); }); -test("explains forbidden management access without offering mutation controls", async () => { - server.use( - http.get("/api/pi-management/status", () => - HttpResponse.json({ code: "pi_management_forbidden", error: "Pi management is not permitted" }, { status: 403 })), - ); +test("explains forbidden management access without offering controls", async () => { + server.use(http.get("/api/pi-management/status", () => + HttpResponse.json( + { code: "pi_management_forbidden", error: "Pi management is not permitted" }, + { status: 403 }, + ))); renderManagement(); - expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted"); - expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Test saved defaults" })).not.toBeInTheDocument(); + expect(await screen.findByRole("alert", { name: "Pi management unavailable" })) + .toHaveTextContent("Pi management is not permitted"); + expect(screen.queryByRole("button", { name: "Test catalog default" })).not.toBeInTheDocument(); }); -test("shows a seven-step host-terminal workflow in scrollable platform tabs", async () => { +test("shows the single-source host workflow in accessible platform tabs", async () => { const user = userEvent.setup(); renderManagement(); const tablist = await screen.findByRole("tablist", { name: "Pi host platform" }); const [linuxTab, macosTab, windowsTab] = within(tablist).getAllByRole("tab"); expect([linuxTab, macosTab, windowsTab].map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]); - expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveAttribute("data-width", "standard"); expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll"); - expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument(); - expect([linuxTab, macosTab, windowsTab].every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true); - expect(linuxTab).toHaveAttribute("tabindex", "0"); - expect(macosTab).toHaveAttribute("tabindex", "-1"); - expect(windowsTab).toHaveAttribute("tabindex", "-1"); - for (const tab of [linuxTab, macosTab, windowsTab]) { - const panelId = tab.getAttribute("aria-controls"); - const panel = panelId ? document.getElementById(panelId) : null; - expect(panel).toBeInTheDocument(); - expect(panel).toHaveAttribute("role", "tabpanel"); - expect(panel).toHaveAttribute("aria-labelledby", tab.id); - expect(panel).toHaveAttribute("hidden"); - expect(panel).toHaveAttribute("tabindex", "-1"); - } - expect(screen.getByText("Using the host terminal:")).toBeVisible(); - expect(screen.queryByText(/not this browser page/i)).not.toBeInTheDocument(); - expect(screen.queryByRole("region", { name: "Host update" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Copy update command" })).not.toBeInTheDocument(); - expect(screen.queryByText(/:5173/)).not.toBeInTheDocument(); - expect(await screen.findByText("Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.")).toBeVisible(); - expect(screen.getByText("Shows at most 200 recent lines with declared secret values removed.")).toBeVisible(); linuxTab.focus(); await user.keyboard("{ArrowRight}"); expect(macosTab).toHaveFocus(); - expect(macosTab).toHaveAttribute("aria-selected", "true"); - expect(macosTab).toHaveAttribute("tabindex", "0"); - expect(linuxTab).toHaveAttribute("tabindex", "-1"); - await user.keyboard("{ArrowRight}"); - expect(windowsTab).toHaveFocus(); - expect(windowsTab).toHaveAttribute("aria-selected", "true"); - expect(windowsTab).toHaveAttribute("tabindex", "0"); - expect(macosTab).toHaveAttribute("tabindex", "-1"); - await user.keyboard("{ArrowRight}"); - expect(linuxTab).toHaveFocus(); - expect(linuxTab).toHaveAttribute("aria-selected", "true"); - await user.keyboard("{ArrowLeft}"); - expect(windowsTab).toHaveFocus(); - await user.keyboard("{Home}"); - expect(linuxTab).toHaveFocus(); await user.keyboard("{End}"); expect(windowsTab).toHaveFocus(); - - await user.click(windowsTab); - expect(windowsTab).toHaveAttribute("aria-selected", "false"); - expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument(); - await user.keyboard(" "); - expect(windowsTab).toHaveAttribute("aria-selected", "true"); - await user.keyboard(" "); - expect(windowsTab).toHaveAttribute("aria-selected", "false"); - await user.keyboard("{Enter}"); - expect(windowsTab).toHaveAttribute("aria-selected", "true"); - await user.keyboard("{Enter}"); - expect(windowsTab).toHaveAttribute("aria-selected", "false"); - await user.click(linuxTab); + const linux = screen.getByRole("tabpanel", { name: "Linux" }); expect(within(linux).getAllByRole("listitem")).toHaveLength(7); expect(within(linux).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([ "Open the project root", - "Edit the provider catalog", - "Enable the model", + "Edit the Installation Model Catalog", + "Choose eligibility and defaults", "Complete the provider setup", "Reload Pi configuration", "Update the Pi version", "Diagnose and recover a failed operation", ]); - expect(linux).toHaveTextContent("cd /absolute/path/to/ThothII"); - expect(linux).toHaveTextContent("tht pi status"); - expect(linux).toHaveTextContent("tht pi doctor"); - expect(linux).toHaveTextContent("Invalid JSON"); - expect(linux).toHaveTextContent("Provider connectivity"); - expect(linux).toHaveTextContent("If maintenance is inactive"); - expect(linux).toHaveTextContent("active after an update"); - expect(linux).toHaveTextContent("active after a restart"); - expect(linux).toHaveTextContent("run every command in this list from that directory"); - expect(linux).toHaveTextContent("deploy/pi/models.json"); - expect(linux).toHaveTextContent("deploy/pi/settings.json"); - expect(linux).toHaveTextContent("baseUrl is the provider API endpoint"); - expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers"); - expect(linux).toHaveTextContent("During the initial installation"); - expect(linux).toHaveTextContent("Pi credentials file location"); - expect(linux).toHaveTextContent("tht pi restart --yes --drain"); - expect(linux).toHaveTextContent("tht pi update"); - expect(linux).toHaveTextContent("tht pi update --version --source pull --image @sha256: --yes --drain"); - expect(linux).toHaveTextContent("tht pi maintenance status"); - expect(linux).toHaveTextContent("tht pi logs"); - expect(linux).toHaveTextContent("tht pi rollback --yes"); - expect(linux).toHaveTextContent("tht pi maintenance recover --yes"); - expect(linux).not.toHaveTextContent("PI_AUTH_FILE"); - expect(linux).not.toHaveTextContent("thothctl"); - expect(linux).not.toHaveTextContent("~/.pi/agent/"); + for (const expected of [ + "deploy//thothii-installation.yaml", + "modelCatalog.defaults.session", + "provider/model", + "generated projections", + "tht pi restart --yes --drain", + "tht pi doctor", + "Invalid YAML", + ]) expect(linux).toHaveTextContent(expected); + for (const retired of ["deploy/pi/models.json", "deploy/pi/settings.json", "tht pi configure", "enabledModels"]) + expect(linux).not.toHaveTextContent(retired); await user.click(macosTab); - const macos = screen.getByRole("tabpanel", { name: "macOS" }); - expect(within(macos).getAllByRole("listitem")).toHaveLength(7); - expect(within(macos).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([ - "Open the project root", - "Edit the provider catalog", - "Enable the model", - "Complete the provider setup", - "Reload Pi configuration", - "Update the Pi version", - "Diagnose and recover a failed operation", - ]); - expect(macos).toHaveTextContent("cd /absolute/path/to/ThothII"); - expect(macos).toHaveTextContent("run every command in this list from that directory"); - expect(macos).toHaveTextContent("deploy/pi/models.json"); - expect(macos).toHaveTextContent("deploy/pi/settings.json"); - expect(macos).toHaveTextContent("tht pi restart --yes --drain"); - expect(macos).toHaveTextContent("tht pi update"); - expect(macos).toHaveTextContent("tht pi update --version --source pull --image @sha256: --yes --drain"); - expect(macos).toHaveTextContent("tht pi maintenance status"); - expect(macos).toHaveTextContent("tht pi logs"); - expect(macos).toHaveTextContent("tht pi rollback --yes"); - expect(macos).toHaveTextContent("tht pi maintenance recover --yes"); - + expect(screen.getByRole("tabpanel", { name: "macOS" })) + .toHaveTextContent("deploy//thothii-installation.yaml"); await user.click(windowsTab); - const windows = screen.getByRole("tabpanel", { name: "Windows" }); - expect(within(windows).getAllByRole("listitem")).toHaveLength(7); - expect(within(windows).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([ - "Open the project root", - "Edit the provider catalog", - "Enable the model", - "Complete the provider setup", - "Reload Pi configuration", - "Update the Pi version", - "Diagnose and recover a failed operation", - ]); - expect(windows).toHaveTextContent("Set-Location C:\\absolute\\path\\to\\ThothII"); - expect(windows).toHaveTextContent("run every command in this list from that directory"); - expect(windows).toHaveTextContent("deploy\\pi\\models.json"); - expect(windows).toHaveTextContent("deploy\\pi\\settings.json"); - expect(windows).toHaveTextContent('tht pi restart --yes --drain'); - expect(windows).toHaveTextContent('tht pi update'); - expect(windows).toHaveTextContent('tht pi update --version --source pull --image @sha256: --yes --drain'); - expect(windows).toHaveTextContent('tht pi maintenance status'); - expect(windows).toHaveTextContent('tht pi logs'); - expect(windows).toHaveTextContent('tht pi rollback --yes'); - expect(windows).toHaveTextContent('tht pi maintenance recover --yes'); - expect(windows).not.toHaveTextContent("PI_AUTH_FILE"); - expect(windows).not.toHaveTextContent("thothctl"); + expect(screen.getByRole("tabpanel", { name: "Windows" })) + .toHaveTextContent("deploy\\\\thothii-installation.yaml"); }); - -test("reloads installation defaults when the panel is reopened", async () => { +test("reloads the catalog-derived default when reopened", async () => { let statusCalls = 0; server.use(http.get("/api/pi-management/status", () => { statusCalls += 1; @@ -334,70 +156,32 @@ test("reloads installation defaults when the panel is reopened", async () => { : { ...readyStatus, config: { provider: "deepseek", model: "deepseek-v4", reasoning: "high" } }); })); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const view = render( undefined} />); + const view = render( + undefined} />, + ); - expect(await screen.findByLabelText("Provider")).toHaveValue("zai"); - view.rerender( undefined} />); + expect(await screen.findByText("glm-5.2")).toBeVisible(); + view.rerender( + undefined} />, + ); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument()); - view.rerender( undefined} />); - - await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek")); + view.rerender( + undefined} />, + ); + expect(await screen.findByText("deepseek-v4")).toBeVisible(); }); -test("shows an explicit recoverable incomplete state when no provider model is available", async () => { - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), - http.get("/api/pi-management/options", () => HttpResponse.json({ - ...options, - providers: ["zai"], - models: [], - })), - ); +test("shows an explicit incomplete state when the catalog default is unavailable", async () => { + server.use(http.get("/api/pi-management/status", () => HttpResponse.json({ + ...readyStatus, + config: { reasoning: "medium" }, + }))); renderManagement(); const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" }); - expect(incomplete).toHaveTextContent("No provider or model options are available"); - expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration"); - expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); -}); - -test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => { - let configured = false; - const persisted = { - ...readyStatus, - credentials: "missing", - config: { provider: "retired", model: "old-model", reasoning: "medium" }, - }; - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json(configured ? { - ...readyStatus, - credentials: "missing", - config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, - checkedAt: "2026-08-05T10:05:00.000Z", - } : persisted)), - http.put("/api/pi-management/config", () => { - configured = true; - return HttpResponse.json({ - provider: "zai", - model: "glm-5.2", - reasoning: "medium", - updatedAt: "2026-08-05T10:05:00.000Z", - }); - }), - ); - const user = userEvent.setup(); - renderManagement(); - - expect(await screen.findByLabelText("Provider")).toHaveValue("zai"); - expect(screen.getByText("Defaults incomplete")).toBeVisible(); - expect(screen.getByText("Suggested choices are not saved yet.")).toBeVisible(); - expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); - - await user.click(screen.getByRole("button", { name: "Save defaults" })); - expect(await screen.findByText("Defaults ready")).toBeVisible(); - expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeEnabled(); + expect(incomplete).toHaveTextContent("The catalog default is unavailable"); + expect(incomplete).toHaveTextContent("modelCatalog.defaults.session"); + expect(screen.getByRole("button", { name: "Test catalog default" })).toBeDisabled(); }); test.each(["present", "missing"] as const)( @@ -408,47 +192,6 @@ test.each(["present", "missing"] as const)( credentials, }))); renderManagement(); - expect(await screen.findByText(`Credentials ${credentials}`)).toBeVisible(); }, ); - -test("labels smoke only as a saved-configuration test and resets it when the draft changes", async () => { - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), - http.post("/api/pi-management/test", () => HttpResponse.json({ - ready: true, - checkedAt: "2026-08-05T10:06:00.000Z", - })), - ); - const user = userEvent.setup(); - renderManagement(); - - await user.click(await screen.findByRole("button", { name: "Test saved defaults" })); - expect(await screen.findByText("Saved configuration test passed")).toBeVisible(); - expect(screen.getByText("Credentials present")).toBeVisible(); - - await user.selectOptions(screen.getByLabelText("Provider"), "deepseek"); - expect(screen.getByText("Saved configuration test not run")).toBeVisible(); - expect(screen.getByText("Save these changes before testing. The test always uses saved defaults.")).toBeVisible(); - expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled(); - expect(screen.getByText("Credentials present")).toBeVisible(); -}); - -test("a failed saved-configuration test does not change backend credential presence", async () => { - server.use( - http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })), - http.post("/api/pi-management/test", () => HttpResponse.json({ - ready: false, - message: "Pi runtime is unavailable", - checkedAt: "2026-08-05T10:07:00.000Z", - })), - ); - const user = userEvent.setup(); - renderManagement(); - - await user.click(await screen.findByRole("button", { name: "Test saved defaults" })); - expect(await screen.findByText("Saved configuration test failed")).toBeVisible(); - expect(screen.getByText("Credentials present")).toBeVisible(); - expect(screen.queryByText("Credentials missing")).not.toBeInTheDocument(); -}); diff --git a/frontend/src/shell/PiManagement.tsx b/frontend/src/shell/PiManagement.tsx index a7189bc0..a9e0a955 100644 --- a/frontend/src/shell/PiManagement.tsx +++ b/frontend/src/shell/PiManagement.tsx @@ -1,59 +1,18 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } from "lucide-react"; +import { CheckCircle2, CircleAlert, FlaskConical, LoaderCircle, ScrollText } from "lucide-react"; import { asPiManagementApiError, getPiManagementLogs, - getPiManagementOptions, getPiManagementStatus, runPiManagementTest, - savePiManagementConfig, - type PiInstallationConfig, - type PiManagementOptions, } from "../api/pi-management"; import { Button } from "../components/ui/button"; import { WorkAreaPanel } from "./WorkAreaPanel"; -const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60"; - type Feedback = { tone: "success" | "error"; message: string } | undefined; type SmokeState = "passed" | "failed" | undefined; -function suggestedConfig(status: { config: Partial }, options: PiManagementOptions): PiInstallationConfig | undefined { - const provider = status.config.provider && options.providers.includes(status.config.provider) - ? status.config.provider - : options.providers[0]; - const model = status.config.model && options.models.some((item) => item.provider === provider && item.id === status.config.model) - ? status.config.model - : options.models.find((item) => item.provider === provider)?.id; - const reasoning = status.config.reasoning && options.reasoning.includes(status.config.reasoning) - ? status.config.reasoning - : options.reasoning[0]; - return provider && model && reasoning ? { provider, model, reasoning } : undefined; -} - -function isSupportedConfig( - config: Partial | undefined, - options: PiManagementOptions | undefined, -): config is PiInstallationConfig { - return Boolean( - config?.provider - && config.model - && config.reasoning - && options?.providers.includes(config.provider) - && options.models.some((model) => model.provider === config.provider && model.id === config.model) - && options.reasoning.includes(config.reasoning), - ); -} - -function sameConfig(a: Partial | undefined, b: PiInstallationConfig | undefined): boolean { - return Boolean( - a?.provider === b?.provider - && a?.model === b?.model - && a?.reasoning === b?.reasoning, - ); -} - function errorMessage(error: unknown, fallback: string): string { return asPiManagementApiError(error)?.message ?? fallback; } @@ -97,15 +56,10 @@ function RailItem({ label, value, state }: { label: string; value: string; state ; } -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ; -} - type PiPlatform = "linux" | "macos" | "windows"; type PiPlatformDetails = { - modelsPath: string; - settingsPath: string; + catalogPath: string; terminal: string; changeDirectoryCommand: string; credentialProtection: string; @@ -123,8 +77,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet id: "linux", label: "Linux", details: { - modelsPath: "deploy/pi/models.json", - settingsPath: "deploy/pi/settings.json", + catalogPath: "deploy//thothii-installation.yaml", terminal: "a terminal", changeDirectoryCommand: "cd /absolute/path/to/ThothII", credentialProtection: "a protected host file with mode 0600", @@ -141,8 +94,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet id: "macos", label: "macOS", details: { - modelsPath: "deploy/pi/models.json", - settingsPath: "deploy/pi/settings.json", + catalogPath: "deploy//thothii-installation.yaml", terminal: "Terminal", changeDirectoryCommand: "cd /absolute/path/to/ThothII", credentialProtection: "a protected host file with mode 0600", @@ -159,8 +111,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet id: "windows", label: "Windows", details: { - modelsPath: "deploy\\pi\\models.json", - settingsPath: "deploy\\pi\\settings.json", + catalogPath: "deploy\\\\thothii-installation.yaml", terminal: "PowerShell", changeDirectoryCommand: "Set-Location C:\\absolute\\path\\to\\ThothII", credentialProtection: "a protected host file with a user-only ACL", @@ -189,37 +140,37 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) { project-root/ ├── compose.yaml ├── deploy/ -│ └── pi/ -│ ├── models.json -│ └── settings.json +│ └── <installation-id>/ +│ └── thothii-installation.yaml └── docker/

The deploy/ directory contains the selected installation descriptor and profile files. You do not need to create or manage a bin/ directory: tht is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with --installation <absolute-path>/thothii-installation.yaml. If tht is not on your PATH, install it using the installation guide.

  • -

    Edit the provider catalog

    -

    Edit {details.modelsPath} only when adding or correcting a provider/model definition. The provider catalog is an address book/map of the services Pi can call: each entry supplies the API endpoint and format, and lists the model identifiers offered there. It does not enable a model and never contains credentials. Provider integrations must remain declarative; do not add model-provider code under harness/.pi/extensions/.

    +

    Edit the Installation Model Catalog

    +

    Edit {details.catalogPath} when adding or correcting a provider or model. Its modelCatalog block is the only authored model source for sessions, metadata generation, and embedding. It declares endpoint references and authentication modes, but never secret values. Provider integrations remain declarative; do not add model-provider code under harness/.pi/extensions/ or edit files under generated/.

    -
    baseUrl
    is the provider API endpoint.
    -
    api
    selects the provider API format.
    -
    models
    lists that provider's available models.
    -
    id
    is the model identifier.
    -
    name
    is the model name shown to operators.
    +
    providers
    maps stable provider keys to endpoint, authentication, and runtime adapters.
    +
    models
    maps upstream model keys; the canonical identity is provider/model.
    +
    session
    makes a model selectable for interactive work.
    +
    metadataGeneration
    makes a model available to metadata generation.
    +
    embedding
    declares the one installation embedding identity and its dimensions.
  • -

    Enable the model

    -

    Enable a model after the provider setup is ready. For a custom provider, first define the provider and model ID in {details.modelsPath}; built-in Pi models may already be known without a local catalog entry. Then edit {details.settingsPath} in the project root, add the exact provider/model identifier to enabledModels, and reload Pi. Do not edit it merely to choose the default provider/model: use this page or tht pi configure.

    +

    Choose eligibility and defaults

    +

    Use modelCatalog.defaults.session as the single default for new sessions. Add a session or metadataGeneration block to make a model eligible for that use. When metadata generation is present, set defaults.metadataGeneration. Runtime files such as Pi models.json and settings.json are generated projections and must not be edited.

    -
    enabledModels
    uses provider/model identifiers to choose the models available for new Pi work.
    +
    defaults.session
    contains exactly one canonical provider/model identity.
    +
    defaults.metadataGeneration
    selects the metadata model when that use exists.
  • Complete the provider setup

    -

    During the initial installation, run tht setup and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run tht pi doctor and tht pi test. Never paste credentials into either JSON file, the page, a command, or a log.

    +

    During the initial installation, run tht setup and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run tht pi doctor and tht pi test. Never paste credentials into the installation YAML, this page, a command, or a log: YAML may contain only approved environment-variable names or authentication modes.

  • Reload Pi configuration

    -

    After changing models.json, settings.json, or the provider credential, reload the running core service so it reads the new files. This is a configuration reload, not a Pi version update; it keeps the current image.

    +

    After changing the Installation Model Catalog or a provider credential, reload the running core service. The command validates the YAML and regenerates every runtime projection before recreation. This is a configuration reload, not a Pi version update; it keeps the current image.

    {details.restartCommand}
  • @@ -234,9 +185,9 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {

    Start with these diagnostics, in order:

    {details.diagnosticCommands}
    -
    Configuration
    Invalid JSON: fix the reported file and validate it. A provider/model ID mismatch or a model missing from enabledModels: correct the IDs or policy, then reload.
    +
    Configuration
    Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported modelCatalog field, then reload. Do not repair generated JSON directly.
    Credentials
    Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.
    -
    Provider connectivity
    Wrong baseUrl, network, or provider error: correct the endpoint or network, then retry.
    +
    Provider connectivity
    Wrong baseUrl, network, or provider error: correct the catalog endpoint or network, then retry.
    Docker and disk
    Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.

    If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.

    @@ -325,30 +276,19 @@ function PiPlatformInstructions() { export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) { const queryClient = useQueryClient(); - const [draft, setDraft] = useState(); const [feedback, setFeedback] = useState(); const [smokeState, setSmokeState] = useState(); const [logsRequested, setLogsRequested] = useState(false); const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open }); - const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open }); const logsQuery = useQuery({ queryKey: ["pi-management", "logs"], queryFn: getPiManagementLogs, enabled: open && logsRequested }); - const models = useMemo( - () => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [], - [draft?.provider, optionsQuery.data?.models], + const catalogReady = Boolean( + statusQuery.data?.config.provider + && statusQuery.data.config.model + && statusQuery.data.config.reasoning, ); - const initialDraft = useMemo( - () => statusQuery.data && optionsQuery.data - ? suggestedConfig(statusQuery.data, optionsQuery.data) - : undefined, - [optionsQuery.data, statusQuery.data], - ); - const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data); - const validDraft = isSupportedConfig(draft, optionsQuery.data); - const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft)); useEffect(() => { if (!open) { - setDraft(undefined); setFeedback(undefined); setSmokeState(undefined); setLogsRequested(false); @@ -356,22 +296,6 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => } }, [open, queryClient]); - useEffect(() => { - if (draft || !initialDraft) return; - setDraft(initialDraft); - }, [draft, initialDraft]); - - const saveMutation = useMutation({ - mutationFn: savePiManagementConfig, - onSuccess: async (saved) => { - const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning }; - setDraft(config); - setSmokeState(undefined); - await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] }); - setFeedback({ tone: "success", message: "Defaults saved." }); - }, - onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }), - }); const smokeMutation = useMutation({ mutationFn: runPiManagementTest, onSuccess: (result) => { @@ -379,41 +303,27 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => setFeedback({ tone: result.ready ? "success" : "error", message: result.ready - ? "Saved configuration test passed." - : `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`, + ? "Catalog default test passed." + : `Catalog default test failed.${result.message ? ` ${result.message}` : ""}`, }); }, onError: (error) => { setSmokeState("failed"); - setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") }); + setFeedback({ tone: "error", message: errorMessage(error, "Could not test the catalog default.") }); }, }); - function saveDefaults() { - if (!draft || !validDraft) { - setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." }); - return; - } - saveMutation.mutate(draft); - } - - function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) { - setDraft((current) => current ? update(current) : current); - setSmokeState(undefined); - setFeedback(undefined); - } - - function testSavedDefaults() { - if (!persistedReady || dirty) { - setFeedback({ tone: "error", message: "Save supported defaults before running the test." }); + function testCatalogDefault() { + if (!catalogReady) { + setFeedback({ tone: "error", message: "Fix the Installation Model Catalog before running the test." }); return; } smokeMutation.mutate(); } const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden"; - const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft); - const unavailable = statusQuery.isError || optionsQuery.isError; + const loading = statusQuery.isLoading; + const unavailable = statusQuery.isError; return ( ariaLabel="Pi management" eyebrow="Installation controls" title="Pi management" - description="Review the bundled runtime, set safe defaults, and test the saved provider configuration." + description="Review the bundled runtime, inspect the catalog default, and test its provider configuration." onClose={onClose} closeLabel="Close Pi management" > @@ -430,14 +340,14 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => {forbidden ? (

    Pi management is not permitted

    -

    Ask an installation administrator to manage Pi defaults and diagnostics.

    +

    Ask an installation administrator to manage the model catalog and diagnostics.

    ) : unavailable ? (
    -

    {errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}

    - +

    {errorMessage(statusQuery.error, "Pi management is unavailable")}

    +
    - ) : loading ?

    Loading Pi management…

    : statusQuery.data && optionsQuery.data && ( + ) : loading ?

    Loading Pi management…

    : statusQuery.data && (
    @@ -451,7 +361,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => @@ -462,56 +372,30 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () => {feedback.message}

    } -
    +
    -

    Installation defaults

    Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.

    +

    Installation catalog default

    This value is read from modelCatalog.defaults.session. Change the installation YAML and reload Pi; this page never creates a second model default.

    - {draft ? ( + {catalogReady ? ( <> -
    - - - - - - - - - +
    +

    Provider

    {statusQuery.data.config.provider}

    +

    Model

    {statusQuery.data.config.model}

    +

    Reasoning

    {statusQuery.data.config.reasoning}

    - {dirty &&

    - {persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."} -

    }
    - - +
    - {dirty &&

    Save these changes before testing. The test always uses saved defaults.

    } - {!dirty && !persistedReady &&

    Save supported defaults before testing. The test always uses saved defaults.

    } ) : (
    -

    No provider or model options are available.

    -

    Check the host-managed Pi model configuration, then retry this panel.

    +

    The catalog default is unavailable.

    +

    Fix modelCatalog.defaults.session in the installation YAML, reload Pi, then retry this panel.

    - - - + +
    )} diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index ea000f71..7bf7cbf3 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -185,58 +185,6 @@ test("a settings preflight from user A prevents session POST after user B logs i view.unmount(); }); -test("a workspace-policy preflight from user A prevents session POST after user B logs in", async () => { - workspacePreferences.save({ - workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low", - }); - let releaseWorkspaces!: () => void; - let workspacesStarted!: () => void; - let workspacesSettled!: () => void; - let sessionPosts = 0; - const workspacesGate = new Promise((resolve) => { releaseWorkspaces = resolve; }); - const started = new Promise((resolve) => { workspacesStarted = resolve; }); - const settled = new Promise((resolve) => { workspacesSettled = resolve; }); - server.use( - http.get("/api/workspaces", () => HttpResponse.json([{ - ...workspaceSummaryFixture("psd-clinical", { - displayName: "PSD Clinical", - revision: workspaceRevisionFixture("psd-clinical"), - }), - }])), - http.get("/api/workspaces/psd-clinical", async () => { - workspacesStarted(); - try { - await workspacesGate; - return HttpResponse.json({ - workspace: canonicalWorkspaceFixture("psd-clinical"), - revision: workspaceRevisionFixture("psd-clinical"), - }); - } finally { - workspacesSettled(); - } - }), - http.post("/api/sessions", () => { - sessionPosts += 1; - return HttpResponse.json({ id: "a-session" }); - }), - ); - const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) }; - const view = render(); - const input = screen.getByRole("textbox", { name: /new question/i }); - await userEvent.type(input, "A-policy-question"); - await userEvent.click(screen.getByRole("button", { name: /send/i })); - await started; - - act(() => setAuthState(userB)); - act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "B-entry" })); - releaseWorkspaces(); - await act(async () => { await settled; }); - - expect(sessionPosts).toBe(0); - expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "B-entry" }); - view.unmount(); -}); - test("pressing Enter in the input submits the steer", async () => { let captured: unknown = null; server.use( @@ -338,7 +286,7 @@ test("footer shows cumulative k-token counters after workspace and context gauge expect(thinking.compareDocumentPosition(gauge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); -test("footer limits model choices to the selected workspace policy", async () => { +test("footer exposes every session model from the installation catalog", async () => { server.use( http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", @@ -363,10 +311,10 @@ test("footer limits model choices to the selected workspace policy", async () => const selector = await screen.findByRole("combobox", { name: "Model" }); await waitFor(() => expect(selector).toHaveTextContent("GLM-5.2")); - await waitFor(() => expect(selector).not.toHaveTextContent("DeepSeek V4 Pro")); + await waitFor(() => expect(selector).toHaveTextContent("DeepSeek V4 Pro")); }); -test("switching workspaces replaces an out-of-policy model before session creation", async () => { +test("switching workspaces preserves a globally available catalog model", async () => { let body: unknown; workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", @@ -405,119 +353,14 @@ test("switching workspaces replaces an out-of-policy model before session creati const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" }); await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical")); await userEvent.selectOptions(workspaceSelector, "psd-clinical"); - await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("glm-5.2")); - expect(screen.getByRole("combobox", { name: "Model" })).not.toHaveTextContent("DeepSeek V4 Pro"); + await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek-v4-pro")); + expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("DeepSeek V4 Pro"); await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); await userEvent.click(screen.getByRole("button", { name: /send/i })); await waitFor(() => expect(body).toEqual({ - question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", - })); -}); - -test("immediate submit waits for a switched workspace policy before creating a session", async () => { - let body: unknown; - let releasePolicy!: () => void; - let policyRequestStarted = false; - const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); - workspacePreferences.save({ - workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - }); - const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", - }); - server.use( - http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), - http.get("/api/workspaces", () => HttpResponse.json([ - workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }), - workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }), - ])), - http.get("/api/workspaces/research", () => HttpResponse.json({ - workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), - })), - http.get("/api/workspaces/psd-clinical", async () => { - policyRequestStarted = true; - await policyMayFinish; - return HttpResponse.json({ - workspace: canonicalWorkspaceFixture("psd-clinical", ["zai/glm-5.2"], "zai/glm-5.2"), - revision: revision("psd-clinical"), - }); - }), - http.get("/api/models", () => HttpResponse.json({ models: [ - { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, - { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, - ] })), - http.post("/api/sessions", async ({ request }) => { - body = await request.json(); - return HttpResponse.json({ id: "s1" }); - }), - ); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); - - const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" }); - await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical")); - await userEvent.selectOptions(workspaceSelector, "psd-clinical"); - await waitFor(() => expect(policyRequestStarted).toBe(true)); - await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); - await userEvent.click(screen.getByRole("button", { name: /send/i })); - - expect(body).toBeUndefined(); - expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); - releasePolicy(); - - await waitFor(() => expect(body).toEqual({ - question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", - })); -}); - -test("initial restored workspace waits for its delayed policy before creating a session", async () => { - let body: unknown; - let releasePolicy!: () => void; - let policyRequestStarted = false; - const policyMayFinish = new Promise((resolve) => { releasePolicy = resolve; }); - workspacePreferences.save({ - workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - }); - const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", - }); - server.use( - http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })), - http.get("/api/workspaces", () => HttpResponse.json([ - workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }), - ])), - http.get("/api/workspaces/psd-clinical", async () => { - policyRequestStarted = true; - await policyMayFinish; - return HttpResponse.json({ - workspace: canonicalWorkspaceFixture("psd-clinical", ["zai/glm-5.2"], "zai/glm-5.2"), - revision: revision("psd-clinical"), - }); - }), - http.get("/api/models", () => HttpResponse.json({ models: [ - { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, - { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, - ] })), - http.post("/api/sessions", async ({ request }) => { - body = await request.json(); - return HttpResponse.json({ id: "s1" }); - }), - ); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); - - await waitFor(() => expect(policyRequestStarted).toBe(true)); - await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); - await userEvent.click(screen.getByRole("button", { name: /send/i })); - - expect(body).toBeUndefined(); - expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); - releasePolicy(); - - await waitFor(() => expect(body).toEqual({ - question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium", + question: "q", workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", })); }); @@ -591,70 +434,6 @@ test("failed workspace summaries block creation and report a safe error", async expect(body).toBeUndefined(); }); -test("submit follows a rapid workspace switch instead of waiting for an abandoned policy", async () => { - let body: unknown; - let releaseC!: () => void; - let bPolicyRequestStarted = false; - let cPolicyRequestStarted = false; - const cPolicyMayFinish = new Promise((resolve) => { releaseC = resolve; }); - workspacePreferences.save({ - workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium", - }); - const revision = (id: string) => ({ - id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", - }); - server.use( - http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })), - http.get("/api/workspaces", () => HttpResponse.json([ - workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }), - workspaceSummaryFixture("workspace-b", { displayName: "Workspace B", revision: revision("workspace-b") }), - workspaceSummaryFixture("workspace-c", { displayName: "Workspace C", revision: revision("workspace-c") }), - ])), - http.get("/api/workspaces/research", () => HttpResponse.json({ - workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"), - })), - http.get("/api/workspaces/workspace-b", async () => { - bPolicyRequestStarted = true; - await new Promise(() => undefined); - return HttpResponse.json({}); - }), - http.get("/api/workspaces/workspace-c", async () => { - cPolicyRequestStarted = true; - await cPolicyMayFinish; - return HttpResponse.json({ - workspace: canonicalWorkspaceFixture("workspace-c", ["zai/glm-5.2"], "zai/glm-5.2"), - revision: revision("workspace-c"), - }); - }), - http.get("/api/models", () => HttpResponse.json({ models: [ - { provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true }, - { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true }, - ] })), - http.post("/api/sessions", async ({ request }) => { - body = await request.json(); - return HttpResponse.json({ id: "s1" }); - }), - ); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); - - const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" }); - await waitFor(() => expect(screen.getByRole("option", { name: "workspace-b" })).toBeInTheDocument()); - await userEvent.selectOptions(workspaceSelector, "workspace-b"); - await waitFor(() => expect(bPolicyRequestStarted).toBe(true)); - await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q"); - await userEvent.click(screen.getByRole("button", { name: /send/i })); - await userEvent.selectOptions(workspaceSelector, "workspace-c"); - await waitFor(() => expect(cPolicyRequestStarted).toBe(true)); - - expect(body).toBeUndefined(); - releaseC(); - - await waitFor(() => expect(body).toEqual({ - question: "q", workspaceId: "workspace-c", provider: "zai", model: "glm-5.2", thinking: "medium", - })); -}); - test("pulses the stop dot only while the harness is working", () => { const { rerender } = render(); const dot = () => diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 59c189dd..232f6fbb 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; import { ApiError, apiErrorMessage } from "../api/client"; import { getSettings } from "../api/settings"; -import { getWorkspace, listWorkspaces } from "../api/workspaces"; +import { listWorkspaces } from "../api/workspaces"; import { listModels } from "../api/models"; import { useSessionStore } from "../store/sessionStore"; import { @@ -202,18 +202,10 @@ export function ComposerFooter() { const workspace = preferences.workspaceId ?? settings?.workspace ?? ""; const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace); const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision); - const { data: workspaceRecord, isError: workspacePolicyError } = useQuery({ - queryKey: ["workspace", workspace], - queryFn: () => getWorkspace(workspace), - enabled: selectedWorkspaceHasRevision, - }); const model = preferences.model ?? settings?.model ?? ""; const thinking = preferences.thinking ?? settings?.thinking ?? "medium"; - const allowedModels = workspaceRecord?.workspace.llm_policy.allowed; - const policyModels = allowedModels - ? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`)) - : models; + const policyModels = models; useEffect(() => { if (!workspace) { @@ -226,38 +218,12 @@ export function ComposerFooter() { workspacePolicyGate.rejectSummary(workspace); } else if (selectedWorkspace?.revision) { workspacePolicyGate.select(workspace); + workspacePolicyGate.resolve(workspace); } else { workspacePolicyGate.allowLegacy(workspace); } }, [selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]); - useEffect(() => { - if (!allowedModels?.length) return; - const selected = preferences.provider && preferences.model - ? `${preferences.provider}/${preferences.model}` - : undefined; - if (selected && allowedModels.some((allowed) => allowed === selected)) return; - const replacement = workspaceRecord?.workspace.llm_policy.default - && allowedModels.includes(workspaceRecord.workspace.llm_policy.default) - ? workspaceRecord.workspace.llm_policy.default - : allowedModels[0]; - const separator = replacement.indexOf("/"); - if (separator <= 0 || separator === replacement.length - 1) return; - const next = { - ...preferences, - provider: replacement.slice(0, separator), - model: replacement.slice(separator + 1), - }; - workspacePreferences.save(next); - setPreferences(next); - }, [allowedModels, preferences, workspaceRecord]); - - useEffect(() => { - if (!selectedWorkspace?.revision) return; - if (workspaceRecord) workspacePolicyGate.resolve(workspace); - else if (workspacePolicyError) workspacePolicyGate.reject(workspace); - }, [selectedWorkspace, workspace, workspacePolicyError, workspaceRecord]); - function update(patch: WorkspacePreference) { if (patch.workspaceId && patch.workspaceId !== workspace) { const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId); @@ -275,7 +241,7 @@ export function ComposerFooter() { } const knownModel = policyModels.some((m) => m.id === model); - const showModelFallback = !allowedModels && !knownModel; + const showModelFallback = !knownModel; const contextPct = tokenUsage && tokenUsage.contextWindow > 0 ? tokenUsage.totalTokens / tokenUsage.contextWindow : 0; diff --git a/frontend/src/test/workspace-fixtures.ts b/frontend/src/test/workspace-fixtures.ts index 1519e409..1ceb1c91 100644 --- a/frontend/src/test/workspace-fixtures.ts +++ b/frontend/src/test/workspace-fixtures.ts @@ -2,24 +2,15 @@ import type { CanonicalWorkspace, WorkspaceRevision, WorkspaceSummary } from ".. export function canonicalWorkspaceFixture( id: string, - allowed: `${string}/${string}`[] = ["zai/glm-5.2"], - defaultModel?: `${string}/${string}`, + _allowed: `${string}/${string}`[] = ["zai/glm-5.2"], + _defaultModel?: `${string}/${string}`, ): CanonicalWorkspace { return { - workspace: { schema_version: 3, id, name: id, language: "en" }, + workspace: { schema_version: 4, id, name: id, language: "en" }, dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"], }, - semantic_index: { - vector_store: { - engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine", - }, - embedding: { - provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, - }, - }, - llm_policy: { ...(defaultModel ? { default: defaultModel } : {}), allowed }, }; } diff --git a/frontend/src/workspaces/drafts.ts b/frontend/src/workspaces/drafts.ts index d290a4ef..8087df1a 100644 --- a/frontend/src/workspaces/drafts.ts +++ b/frontend/src/workspaces/drafts.ts @@ -130,10 +130,6 @@ function workspaceId(value: unknown): string | undefined { return typeof value === "string" && /^[a-z][a-z0-9-]{2,62}$/.test(value) ? value : undefined; } -function modelReference(value: unknown): `${string}/${string}` | undefined { - return typeof value === "string" && /^[^/\s]+\/[^/\s]+$/.test(value) ? value as `${string}/${string}` : undefined; -} - function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max ? value : undefined; } @@ -348,17 +344,6 @@ function uniqueChoices(value: unknown, choices: readonly T[]): return result; } -function uniqueModels(value: unknown): `${string}/${string}`[] | undefined { - if (!Array.isArray(value) || value.length === 0) return undefined; - const result: `${string}/${string}`[] = []; - for (const item of value) { - const model = modelReference(item); - if (!model || result.includes(model)) return undefined; - result.push(model); - } - return result; -} - function originRelativePath(value: unknown): string | undefined { return typeof value === "string" && /^\/(?!\/)[^\\\u0000-\u001F\u007F?#]*$/.test(value) && !/%5c/i.test(value) ? value @@ -391,17 +376,11 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined { /** Drops unknown fields before a server response can become a browser draft or conflict view. */ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined { - const source = exactRecord(value, [ - "workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence", - ]); + const source = exactRecord(value, ["workspace", "dwh", "diagnostics", "evidence"]); const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]); const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]); - const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]); - const vectorStore = exactRecord(semanticIndex?.vector_store, ["engine", "collection", "dimensions", "distance"]); - const embedding = exactRecord(semanticIndex?.embedding, ["provider", "model", "dimensions"]); - const policy = exactRecord(source?.llm_policy, ["default", "allowed"]); const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics); - if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined; + if (!metadata || !dwh) return undefined; const id = workspaceId(metadata.id); const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined; const name = text(metadata.name); @@ -412,29 +391,16 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535); const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms); const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const); - const collection = workspaceId(vectorStore.collection); - const vectorDimensions = positiveInteger(vectorStore.dimensions, 32_768); - const distance = oneOf(vectorStore.distance, ["cosine"] as const); - const embeddingProvider = oneOf(embedding.provider, ["ollama_internal"] as const); - const embeddingModel = text(embedding.model); - const embeddingDimensions = positiveInteger(embedding.dimensions, 32_768); - const allowedModels = uniqueModels(policy.allowed); - const defaultModel = policy.default === undefined ? undefined : modelReference(policy.default); if ( - metadata.schema_version !== 3 || !id || !name || !language || (metadata.description !== undefined && !description) + metadata.schema_version !== 4 || !id || !name || !language || (metadata.description !== undefined && !description) || dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports - || vectorStore.engine !== "qdrant" || !collection || !vectorDimensions || !distance - || !embeddingProvider || !embeddingModel || !embeddingDimensions || !allowedModels - || (defaultModel !== undefined && !allowedModels.includes(defaultModel)) || vectorDimensions !== embeddingDimensions - || vectorDimensions !== 1024 || embeddingDimensions !== 1024 - || embeddingModel !== "qwen3-embedding:0.6b" ) return undefined; if (source?.diagnostics !== undefined && !diagnostics) return undefined; if (source?.evidence !== undefined && !evidence) return undefined; if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined; return { workspace: { - schema_version: 3, + schema_version: 4, id, name, ...(description ? { description } : {}), @@ -446,17 +412,6 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | ...(dwhTimeout ? { timeout_ms: dwhTimeout } : {}), supported_transports: dwhTransports, }, - semantic_index: { - vector_store: { - engine: "qdrant", collection, dimensions: 1024, distance: "cosine", - }, - embedding: { - provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024, - }, - }, - llm_policy: { - ...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels, - }, ...(diagnostics ? { diagnostics } : {}), ...(evidence ? { evidence } : {}), }; diff --git a/harness/README.md b/harness/README.md index ab8a6f60..a786d390 100644 --- a/harness/README.md +++ b/harness/README.md @@ -29,8 +29,9 @@ Keys are never logged; URLs are fine. Rotate any key that appeared in chat. ### `workspaces/.yaml` -A schema-v3 workspace wires the external relational DWH to one internal Qdrant collection -and the internal Ollama embedding model. Evidence paths remain workspace-local, while Qdrant +A schema-v4 workspace wires the external relational DWH to one internal Qdrant collection. +The embedding identity and dimensions come from the Installation Model Catalog, not this +workspace descriptor. Evidence paths remain workspace-local, while Qdrant stores the derived semantic projection for schema, Evidence, Memory, and solved-question records. The legacy files under `workspaces/` are retained as migration fixtures; new operator-facing descriptors live in the workspace Git registry. `${THT_*}` tokens expand diff --git a/harness/tests/test_evidence_pi_restructurer.py b/harness/tests/test_evidence_pi_restructurer.py index ba5f0590..feaae1a3 100644 --- a/harness/tests/test_evidence_pi_restructurer.py +++ b/harness/tests/test_evidence_pi_restructurer.py @@ -36,8 +36,7 @@ def _candidate(): def test_pi_restructurer_uses_an_ephemeral_no_tools_invocation(tmp_path, monkeypatch): from tht.evidence import authoring - monkeypatch.setenv("PI_PROVIDER", "zai") - monkeypatch.setenv("PI_MODEL", "glm-5.2") + monkeypatch.setenv("THT_DEFAULT_SESSION_MODEL", "zai/glm-5.2") monkeypatch.setenv("PI_THINKING", "medium") calls = [] diff --git a/harness/tests/test_local_compose_contract.py b/harness/tests/test_local_compose_contract.py index ff4d10e1..4ff08e72 100644 --- a/harness/tests/test_local_compose_contract.py +++ b/harness/tests/test_local_compose_contract.py @@ -34,8 +34,13 @@ def test_local_compose_uses_the_generic_external_endpoint_contract(): assert name in environment assert environment["THT_INTERNAL_QDRANT_URL"] == "http://qdrant:6333" assert environment["THT_INTERNAL_EMBEDDING_URL"] == "http://embedding:11434" - assert environment["THT_INTERNAL_EMBEDDING_MODEL"] == "qwen3-embedding:0.6b" - assert environment["THT_INTERNAL_EMBEDDING_DIMENSIONS"] == "1024" + # Identity and dimensions come only from the installation-generated Compose projection. + for name in ( + "THT_INTERNAL_EMBEDDING_ID", + "THT_INTERNAL_EMBEDDING_MODEL", + "THT_INTERNAL_EMBEDDING_DIMENSIONS", + ): + assert name not in environment for name in ("THT_VEC_REST_URL", "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL"): assert name not in environment @@ -53,6 +58,7 @@ def test_local_compose_uses_the_generic_external_endpoint_contract(): model_init = compose["services"]["embedding-model-init"] assert "ports" not in model_init + assert "OLLAMA_MODEL" not in model_init.get("environment", {}) assert model_init["depends_on"]["embedding"]["condition"] == "service_healthy" assert compose["services"]["core"]["depends_on"]["embedding-model-init"]["condition"] == "service_completed_successfully" diff --git a/harness/tests/test_qdrant_cli_commands.py b/harness/tests/test_qdrant_cli_commands.py index 8acb072a..c1b0ffad 100644 --- a/harness/tests/test_qdrant_cli_commands.py +++ b/harness/tests/test_qdrant_cli_commands.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace +import pytest from typer.testing import CliRunner from tht.cli import app @@ -13,6 +14,12 @@ from tht.memory import MemoryRecord, save_registry from tht.ports.vector import VectorStoreError +@pytest.fixture(autouse=True) +def _ignore_operator_profile(monkeypatch): + """Exercise the self-contained server runtime fixture, not the developer's harness/.env.""" + monkeypatch.delenv("THT_PROFILE", raising=False) + + class _FakeEmbedder: def embed_documents(self, documents): return [[0.1] * 4 for _ in documents] diff --git a/harness/tht/evidence/authoring.py b/harness/tht/evidence/authoring.py index a24595b9..3b383985 100644 --- a/harness/tht/evidence/authoring.py +++ b/harness/tht/evidence/authoring.py @@ -228,14 +228,15 @@ class PiEvidenceRestructurer: "--no-skills", "--extension", str(self._json_mode_extension), ] - for option, environment_name in ( - ("--provider", "PI_PROVIDER"), - ("--model", "PI_MODEL"), - ("--thinking", "PI_THINKING"), - ): - value = os.environ.get(environment_name) - if value: - argv.extend((option, value)) + canonical_model = os.environ.get("THT_DEFAULT_SESSION_MODEL") + if canonical_model: + provider, separator, model = canonical_model.partition("/") + if separator != "/" or not provider or not model: + raise EvidencePreparationError("pi_restructure_failed", request.source_file) + argv.extend(("--provider", provider, "--model", model)) + thinking = os.environ.get("PI_THINKING") + if thinking: + argv.extend(("--thinking", thinking)) argv.extend(( "--system-prompt", system_prompt, f"@{request_path}", diff --git a/scripts/fixtures/workspace-registry-smoke.yaml b/scripts/fixtures/workspace-registry-smoke.yaml index 0a5e5a4e..3021732f 100644 --- a/scripts/fixtures/workspace-registry-smoke.yaml +++ b/scripts/fixtures/workspace-registry-smoke.yaml @@ -1,5 +1,5 @@ workspace: - schema_version: 3 + schema_version: 4 id: local name: Local description: Isolated workspace registry smoke fixture. @@ -15,22 +15,6 @@ dwh: - postgres_direct - rest_api -semantic_index: - vector_store: - engine: qdrant - collection: local - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 - -llm_policy: - default: zai/glm-5.2 - allowed: - - zai/glm-5.2 - evidence: source: type: filesystem diff --git a/scripts/fixtures/workspace-registry-task13.yaml b/scripts/fixtures/workspace-registry-task13.yaml index d9924b86..3d46f967 100644 --- a/scripts/fixtures/workspace-registry-task13.yaml +++ b/scripts/fixtures/workspace-registry-task13.yaml @@ -1,5 +1,5 @@ workspace: - schema_version: 3 + schema_version: 4 id: task13-smoke name: Task 13 Smoke language: en @@ -11,22 +11,6 @@ dwh: supported_transports: - postgres_direct -semantic_index: - vector_store: - engine: qdrant - collection: task13-smoke - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 - -llm_policy: - default: local-qwen/task13-smoke - allowed: - - local-qwen/task13-smoke - evidence: source: type: filesystem diff --git a/scripts/fixtures/workspace-registry-windows.yaml b/scripts/fixtures/workspace-registry-windows.yaml index f9a93547..b2f84ce5 100644 --- a/scripts/fixtures/workspace-registry-windows.yaml +++ b/scripts/fixtures/workspace-registry-windows.yaml @@ -1,5 +1,5 @@ workspace: - schema_version: 3 + schema_version: 4 id: task13-windows name: Task 13 Windows language: en @@ -14,22 +14,6 @@ dwh: - postgres_direct - rest_api -semantic_index: - vector_store: - engine: qdrant - collection: task13-windows - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 - -llm_policy: - default: zai/glm-5.2 - allowed: - - zai/glm-5.2 - evidence: source: type: filesystem diff --git a/scripts/task13-runtime-fixture-check.ts b/scripts/task13-runtime-fixture-check.ts index 9472678f..f0a010b4 100644 --- a/scripts/task13-runtime-fixture-check.ts +++ b/scripts/task13-runtime-fixture-check.ts @@ -86,26 +86,11 @@ for (const name of [ } if (workspace.workspace?.id !== "task13-smoke") throw new Error("fixture workspace id changed"); -if (workspace.semantic_index?.vector_store?.engine !== "qdrant") { - throw new Error("fixture workspace must use qdrant"); -} -if (workspace.semantic_index?.vector_store?.collection !== workspace.workspace?.id) { - throw new Error("fixture workspace must dedicate one qdrant collection per workspace id"); -} -if (workspace.semantic_index?.vector_store?.dimensions !== 1024) { - throw new Error("fixture workspace qdrant dimension changed"); -} -if (workspace.semantic_index?.vector_store?.distance !== "cosine") { - throw new Error("fixture workspace qdrant distance changed"); -} -if (workspace.semantic_index?.embedding?.provider !== "ollama_internal") { - throw new Error("fixture workspace must use internal ollama embeddings"); -} -if (workspace.semantic_index?.embedding?.model !== "qwen3-embedding:0.6b") { - throw new Error("fixture workspace embedding model changed"); -} -if (workspace.semantic_index?.embedding?.dimensions !== 1024) { - throw new Error("fixture workspace embedding dimension changed"); +if (workspace.workspace?.schema_version !== 4) throw new Error("fixture workspace must use schema v4"); +for (const forbidden of ["semantic_index", "llm_policy"]) { + if (Object.hasOwn(workspace, forbidden)) { + throw new Error(`fixture workspace must not own installation model configuration: ${forbidden}`); + } } if (!statSync(bundleSource).isFile()) { @@ -139,28 +124,36 @@ if (profile === "local") { throw new Error("server runtime fixture lacks one readable, read-only password-file bind"); } accessSync(runtimePasswordSourceInput, constants.R_OK); -const piTargets = [ - "/home/thoth/.pi/agent/auth.json", +const generatedPiTargets = [ "/home/thoth/.pi/agent/models.json", "/home/thoth/.pi/agent/settings.json", ] as const; const piParent = mounts.filter((mount: any) => mount.target === "/home/thoth/.pi"); if (piParent.length !== 1) throw new Error("core lacks exactly one Pi state mount"); -for (const target of piTargets) { +for (const target of generatedPiTargets) { const selected = mounts.filter((mount: any) => mount.target === target); + if (selected.length !== 1 || selected[0].type !== "bind" || !selected[0].read_only) { + throw new Error(`Pi fixture mount is not one generated read-only bind: ${target}`); + } + accessSync(selected[0].source, constants.R_OK); if (profile === "local") { - if (piParent[0].type !== "volume" || selected.length !== 0) { - throw new Error(`local Pi fixture must come only from the projected state volume: ${target}`); + if (piParent[0].type !== "volume") { + throw new Error(`local Pi parent is not a state volume: ${target}`); } } else { - if (selected.length !== 1 || selected[0].type !== "bind" || !selected[0].read_only) { - throw new Error(`Pi fixture mount is not one read-only bind: ${target}`); - } - accessSync(selected[0].source, constants.R_OK); const hidden = join(piParent[0].source, "agent", basename(target)); if (!statSync(hidden).isFile()) throw new Error(`server parent root lacks ${hidden}`); } } +const authTarget = "/home/thoth/.pi/agent/auth.json"; +const authMounts = mounts.filter((mount: any) => mount.target === authTarget); +if (profile === "local") { + if (authMounts.length !== 0) { + throw new Error("local Pi auth must be projected into the state volume, not directly mounted"); + } +} else if (authMounts.length !== 1 || authMounts[0].type !== "bind" || !authMounts[0].read_only) { + throw new Error("server Pi auth is not one independent read-only bind"); +} for (const [target, localVolume] of [ ["/run/thothii-auth", "auth-runtime"], diff --git a/scripts/test-compose-provider-readiness.sh b/scripts/test-compose-provider-readiness.sh index 8a88bf78..ae6d0fc9 100755 --- a/scripts/test-compose-provider-readiness.sh +++ b/scripts/test-compose-provider-readiness.sh @@ -1,121 +1,74 @@ #!/usr/bin/env bash -# Fresh Compose flow: mounted Pi policy/auth must produce a selectable, credential-ready provider. +# Base Compose is deliberately model-free; `tht start` appends the generated catalog projection. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" tmp_parent="${TMPDIR:-/tmp}" tmp="$(mktemp -d "${tmp_parent%/}/thoth-provider-readiness.XXXXXX")" -project="thothii-provider-readiness-$$" -compose=( - docker compose --project-name "$project" --env-file "$tmp/local.env" - -f "$root/compose.yaml" -f "$root/deploy/compose.local.yaml" -) -cleanup() { - "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true - rm -rf "$tmp" -} -trap cleanup EXIT HUP INT TERM +trap 'rm -rf "$tmp"' EXIT HUP INT TERM -printf '%s\n' '{"zai":{"type":"api_key","key":"fixture-native-auth-key"}}' >"$tmp/pi-auth.json" +printf '%s\n' '{}' >"$tmp/pi-auth.json" printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$tmp/thothii.secrets" -chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets" -auth_config="$tmp/auth" -mkdir "$auth_config" -chmod 0700 "$auth_config" -printf '%s\n' \ - 'version: 1' \ - 'mode: local' \ - 'publicUrl: http://127.0.0.1:8080' \ - 'local:' \ - ' usersFile: users.yaml' \ - >"$auth_config/auth.yaml" -node - "$auth_config/users.yaml" <<'NODE' -const { argon2 } = require("node:crypto"); -const { writeFileSync } = require("node:fs"); +printf '%s\n' 'schemaVersion: 2' >"$tmp/thothii-installation.yaml" +printf '%s' 'fixture-catalog-runtime-password' >"$tmp/catalog-runtime-password" +printf '%s' 'fixture-catalog-migrator-password' >"$tmp/catalog-migrator-password" +mkdir "$tmp/auth" +printf '%s\n' 'mode: local' >"$tmp/auth/auth.yaml" +chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets" "$tmp/thothii-installation.yaml" \ + "$tmp/catalog-runtime-password" "$tmp/catalog-migrator-password" "$tmp/auth/auth.yaml" +chmod 0700 "$tmp/auth" -const message = Buffer.from("fixture-local-password", "utf8"); -const nonce = Buffer.from([...Array(16).keys()]); -argon2("argon2id", { - message, - nonce, - memory: 65_536, - parallelism: 1, - tagLength: 32, - passes: 3, -}, (error, digest) => { - message.fill(0); - nonce.fill(0); - if (error || !digest) throw error ?? new Error("fixture password hash failed"); - const salt = Buffer.from([...Array(16).keys()]).toString("base64").replaceAll("=", ""); - const hash = digest.toString("base64").replaceAll("=", ""); - writeFileSync(process.argv[2], [ - "version: 1", - "users:", - " - id: 00000000-0000-4000-8000-000000000001", - " username: fixture-user", - " displayName: Fixture user", - ` passwordHash: $argon2id$v=19$m=65536,t=3,p=1$${salt}$${hash}`, - " roles:", - " - user", - " enabled: true", - " authRevision: 1", - "", - ].join("\\n"), { mode: 0o600 }); -}); -NODE -chmod 0600 "$auth_config/auth.yaml" "$auth_config/users.yaml" -printf '%s\n' \ - 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \ - "PI_AUTH_FILE=$tmp/pi-auth.json" \ - "THT_SECRETS_FILE=$tmp/thothii.secrets" \ - "THT_AUTH_CONFIG_ROOT=$auth_config" \ - 'THOTH_CORE_HTTP_PORT=0' \ - 'THOTH_HTTP_PORT=0' \ - >"$tmp/local.env" +rendered="$tmp/rendered.json" +THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \ +PI_AUTH_FILE="$tmp/pi-auth.json" \ +THT_SECRETS_FILE="$tmp/thothii.secrets" \ +THT_INSTALLATION_CONFIG_SOURCE="$tmp/thothii-installation.yaml" \ +THT_CATALOG_RUNTIME_PASSWORD_SOURCE="$tmp/catalog-runtime-password" \ +THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="$tmp/catalog-migrator-password" \ +THT_AUTH_CONFIG_ROOT="$tmp/auth" \ +docker compose --project-directory "$root" \ + -f "$root/compose.yaml" -f "$root/deploy/compose.local.yaml" \ + config --format json >"$rendered" -"${compose[@]}" up --detach --wait --wait-timeout 90 --build core -core_id="$("${compose[@]}" ps -q core)" -core_address="$("${compose[@]}" port core 8787 | head -n 1)" - -"${compose[@]}" exec -T core sh -ceu ' - test -r /home/thoth/.pi/agent/auth.json - test -r /home/thoth/.pi/agent/models.json - test -r /home/thoth/.pi/agent/settings.json - test -r /run/secrets/thothii.secrets -' - -curl --fail --silent --show-error "http://$core_address/models" >"$tmp/models.json" -node - "$tmp/models.json" <<'NODE' -const fs = require("fs"); -const body = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -if (!body.models?.some((model) => model.provider === "zai" && model.id === "glm-5.2")) { - throw new Error("fresh Compose did not expose the mounted Pi-enabled model"); +node - "$rendered" <<'NODE' +const { readFileSync } = require("node:fs"); +const config = JSON.parse(readFileSync(process.argv[2], "utf8")); +const core = config.services?.core; +if (!core) throw new Error("base Compose lacks core"); +for (const name of [ + "THT_MODEL_CATALOG_FILE", + "THT_MODEL_CATALOG_REVISION", + "THT_DEFAULT_SESSION_MODEL", + "THT_INTERNAL_EMBEDDING_ID", + "THT_INTERNAL_EMBEDDING_MODEL", + "THT_INTERNAL_EMBEDDING_DIMENSIONS", +]) { + if (Object.hasOwn(core.environment || {}, name)) { + throw new Error(`base Compose invented installation-owned model input ${name}`); + } +} +for (const target of (core.volumes || []).map((mount) => mount.target)) { + if (target === "/run/thothii-model-catalog/catalog.json" + || target === "/home/thoth/.pi/agent/models.json" + || target === "/home/thoth/.pi/agent/settings.json") { + throw new Error(`base Compose mounted a generated model adapter: ${target}`); + } +} +const installation = (core.configs || []).filter( + (entry) => entry.target === "/run/thothii-installation/thothii-installation.yaml", +); +if (installation.length !== 1 || installation[0].source !== "thothii_installation_config") { + throw new Error("base Compose lacks the protected installation descriptor mount"); } NODE -curl --fail --silent --show-error -X PUT \ - -H 'content-type: application/json' \ - --data '{"provider":"zai","model":"glm-5.2","reasoning":"low"}' \ - "http://$core_address/pi-management/config" >"$tmp/configured.json" -curl --fail --silent --show-error \ - "http://$core_address/pi-management/status" >"$tmp/status.json" -node - "$tmp/status.json" <<'NODE' -const fs = require("fs"); -const body = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -if (!body.ready || body.credentials !== "present") { - throw new Error("mounted Pi provider is not credential-ready"); -} -if (body.config?.provider !== "zai" || body.config?.model !== "glm-5.2") { - throw new Error("Pi provider configuration was not persisted"); -} -NODE +if grep -Fq 'fixture-model-api-key' "$rendered"; then + echo "rendered base Compose leaked the model key" >&2 + exit 1 +fi +if [[ -e "$root/deploy/pi/models.json" || -e "$root/deploy/pi/settings.json" ]]; then + echo "legacy authored Pi model sources still exist" >&2 + exit 1 +fi -inspect="$(docker inspect "$core_id")" -for secret in fixture-native-auth-key fixture-model-api-key; do - if grep -Fq "$secret" <<<"$inspect"; then - echo "container inspection leaked $secret" >&2 - exit 1 - fi -done - -echo "Compose provider-readiness contract passed." +echo "Base Compose model fail-closed contract passed." diff --git a/scripts/test-container-deployment.sh b/scripts/test-container-deployment.sh index 2bacefad..d28f111e 100755 --- a/scripts/test-container-deployment.sh +++ b/scripts/test-container-deployment.sh @@ -13,9 +13,17 @@ printf '%s\n' '{"zai":{"type":"api_key","key":"fixture-native-auth-key"}}' >"$tm chmod 0600 "$tmp/pi-auth.json" printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$tmp/thothii.secrets" chmod 0600 "$tmp/thothii.secrets" +printf '%s\n' 'schemaVersion: 2' >"$tmp/thothii-installation.yaml" +chmod 0600 "$tmp/thothii-installation.yaml" +printf '%s' 'fixture-catalog-runtime-password' >"$tmp/catalog-runtime-password" +printf '%s' 'fixture-catalog-migrator-password' >"$tmp/catalog-migrator-password" +chmod 0600 "$tmp/catalog-runtime-password" "$tmp/catalog-migrator-password" export PI_AUTH_FILE="$tmp/pi-auth.json" export THT_SECRETS_FILE="$tmp/thothii.secrets" +export THT_INSTALLATION_CONFIG_SOURCE="$tmp/thothii-installation.yaml" +export THT_CATALOG_RUNTIME_PASSWORD_SOURCE="$tmp/catalog-runtime-password" +export THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="$tmp/catalog-migrator-password" export THT_WORKSPACE_GIT_REMOTE="https://git.example.invalid/thothii/workspaces.git" # Let Docker assign loopback ports so this isolated contract test never collides with an operator stack. export THOTH_CORE_HTTP_PORT=0 @@ -66,8 +74,6 @@ docker compose --project-name "$project" -f compose.yaml -f deploy/compose.local command -v pi >/dev/null test ! -e /var/run/docker.sock test -r /home/thoth/.pi/agent/auth.json - test -r /home/thoth/.pi/agent/models.json - test -r /home/thoth/.pi/agent/settings.json test -r /run/secrets/thothii.secrets touch /data/.task5-writable rm /data/.task5-writable diff --git a/scripts/test-default-compose.sh b/scripts/test-default-compose.sh index 054124ad..4b1a8357 100755 --- a/scripts/test-default-compose.sh +++ b/scripts/test-default-compose.sh @@ -86,8 +86,6 @@ for (const [key, value] of Object.entries({ THT_AUTH_STATE_ROOT: "/data/auth", THT_INTERNAL_QDRANT_URL: "http://qdrant:6333", THT_INTERNAL_EMBEDDING_URL: "http://embedding:11434", - THT_INTERNAL_EMBEDDING_MODEL: "qwen3-embedding:0.6b", - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024", THT_CATALOG_DB_HOST: "catalog-db", THT_CATALOG_DB_NAME: "thothii_catalog", THT_CATALOG_RUNTIME_USER: "thothii_catalog_runtime", @@ -95,6 +93,9 @@ for (const [key, value] of Object.entries({ })) { if (env[key] !== value) throw new Error(`unexpected core ${key}: ${env[key]}`); } +for (const key of ["THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "THT_INTERNAL_EMBEDDING_DIMENSIONS"]) { + if (Object.hasOwn(env, key)) throw new Error(`${key} must come only from the generated installation projection`); +} const authConfigMounts = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth"); if (authConfigMounts.length !== 1 || authConfigMounts[0].type !== "bind" || !authConfigMounts[0].read_only) { throw new Error("core must receive exactly one read-only authentication configuration bind"); diff --git a/scripts/test-pi-user-auth-compose.sh b/scripts/test-pi-user-auth-compose.sh index 582ab9c7..20fd5331 100755 --- a/scripts/test-pi-user-auth-compose.sh +++ b/scripts/test-pi-user-auth-compose.sh @@ -16,20 +16,22 @@ chmod 0600 "$auth_config/auth.yaml" secrets_file="$tmp/thothii.secrets" printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$secrets_file" chmod 0600 "$secrets_file" +installation_file="$tmp/thothii-installation.yaml" +printf '%s\n' 'schemaVersion: 2' >"$installation_file" +chmod 0600 "$installation_file" rendered=$(THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \ PI_AUTH_FILE="$auth_file" THT_SECRETS_FILE="$secrets_file" \ - THT_AUTH_CONFIG_ROOT="$auth_config" docker compose config) + THT_AUTH_CONFIG_ROOT="$auth_config" THT_INSTALLATION_CONFIG_SOURCE="$installation_file" \ + docker compose config) printf '%s\n' "$rendered" | grep -q "source: $auth_file" printf '%s\n' "$rendered" | grep -q 'target: /home/thoth/.pi/agent/auth.json' printf '%s\n' "$rendered" | grep -A4 'target: /home/thoth/.pi/agent/auth.json' \ | grep -q 'read_only: true' -for target in \ - /home/thoth/.pi/agent/models.json \ - /home/thoth/.pi/agent/settings.json; do - printf '%s\n' "$rendered" | grep -q "target: $target" - printf '%s\n' "$rendered" | grep -A4 "target: $target" | grep -q 'read_only: true' -done +if printf '%s\n' "$rendered" | grep -Eq '/home/thoth/\.pi/agent/(models|settings)\.json'; then + echo "base Compose must not mount model projections without the generated override" >&2 + exit 1 +fi printf '%s\n' "$rendered" | grep -q "file: $secrets_file" printf '%s\n' "$rendered" | grep -q 'target: thothii.secrets' if grep -Fq 'fixture-model-api-key' <<<"$rendered"; then @@ -39,12 +41,14 @@ fi dev_rendered=$(THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git \ PI_AUTH_FILE="$auth_file" THT_SECRETS_FILE="$secrets_file" \ - THT_AUTH_CONFIG_ROOT="$auth_config" \ + THT_AUTH_CONFIG_ROOT="$auth_config" THT_INSTALLATION_CONFIG_SOURCE="$installation_file" \ docker compose --env-file deploy/env/local.env.example -f docker-compose.dev.yml config) printf '%s\n' "$dev_rendered" | grep -q "source: $auth_file" printf '%s\n' "$dev_rendered" | grep -q 'target: /home/thoth/.pi/agent/auth.json' -printf '%s\n' "$dev_rendered" | grep -q 'target: /home/thoth/.pi/agent/models.json' -printf '%s\n' "$dev_rendered" | grep -q 'target: /home/thoth/.pi/agent/settings.json' +if printf '%s\n' "$dev_rendered" | grep -Eq '/home/thoth/\.pi/agent/(models|settings)\.json'; then + echo "development Compose must not contain legacy model sources" >&2 + exit 1 +fi printf '%s\n' "$dev_rendered" | grep -q "file: $secrets_file" printf '%s\n' "$dev_rendered" | grep -q 'target: thothii.secrets' if grep -Fq 'fixture-model-api-key' <<<"$dev_rendered"; then @@ -52,27 +56,8 @@ if grep -Fq 'fixture-model-api-key' <<<"$dev_rendered"; then exit 1 fi -python3 - <<'PY' -import json -from pathlib import Path - -settings = json.loads(Path("deploy/pi/settings.json").read_text()) -assert settings["enabledModels"] == [ - "zai/glm-5.3", - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", - "local-qwen/qwen3.6-35b-a3b", -] -models = json.loads(Path("deploy/pi/models.json").read_text()) -qwen = models["providers"]["local-qwen"] -assert qwen["api"] == "openai-completions" -assert qwen["models"][0]["id"] == "qwen3.6-35b-a3b" -assert qwen["models"][0]["compat"]["maxTokensField"] == "max_tokens" -assert "aritmolab" not in models["providers"] -PY - if grep -R -n 'registerProvider' harness/.pi/extensions; then - echo "Pi model providers must be declared in deploy/pi/models.json, not in code" >&2 + echo "Pi model providers must be declared in the Installation Model Catalog, not in code" >&2 exit 1 fi diff --git a/scripts/test-task13-runtime-fixtures.sh b/scripts/test-task13-runtime-fixtures.sh index 6052be6b..dfb16578 100755 --- a/scripts/test-task13-runtime-fixtures.sh +++ b/scripts/test-task13-runtime-fixtures.sh @@ -41,8 +41,6 @@ TASK13_AUTHENTIK_API_TOKEN="fixture-authentik-token-$profile" TASK13_FRONTEND_PORT=18080 TASK13_SESSION_RUNTIME_PASSWORD="$fixture/runtime-password" TASK13_SESSION_CA="$fixture/session-ca.pem" -TASK13_PI_MODELS="$fixture/models.json" -TASK13_PI_SETTINGS="$fixture/settings.json" TASK13_LLM_SERVER="$fixture/fake-llm.mjs" TASK13_LLM_CONTAINER="$TASK13_PROJECT-llm" TASK13_REMOTE="$fixture/remote.git" @@ -54,22 +52,8 @@ cp "$root/scripts/fixtures/workspace-registry-task13.yaml" "$workspace" if [[ "$profile" == local ]]; then task13_write_fixture_files - node - "$TASK13_PI_MODELS" <<'NODE' -const fs = require("fs"); -const models = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const model = models.providers?.["local-qwen"]?.models?.find( - (candidate) => candidate?.id === "task13-smoke", -); -if (!model || JSON.stringify(model.input) !== JSON.stringify(["text"])) { - throw new Error("Task 13 provider smoke model must declare text input support"); -} -const expectedCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; -if (JSON.stringify(model.cost) !== JSON.stringify(expectedCost)) { - throw new Error("Task 13 provider smoke model must declare complete zero-cost metadata"); -} -NODE task13_write_environment /fixtures/remote.git - compose_files=(-f "$root/compose.yaml" -f "$root/deploy/compose.local.yaml" -f "$TASK13_OVERRIDE") + compose_files=(-f "$root/compose.yaml" -f "$root/deploy/compose.local.yaml" -f "$TASK13_OVERRIDE" -f "$fixture/generated/compose.models.yaml") else TASK13_SERVER_DATA="$fixture/Server Data" TASK13_SERVER_PI_STATE="$fixture/Server Pi State" @@ -106,6 +90,7 @@ else -f "$root/deploy/compose.server.yaml" -f "$root/deploy/compose.session-server.yaml.example" -f "$TASK13_OVERRIDE" + -f "$fixture/generated/compose.models.yaml" -f "$root/deploy/compose.auth-runtime-projection.yaml" ) fi @@ -188,7 +173,7 @@ NODE fi done -for mutation in collection-reuse dimension-change; do +for mutation in workspace-semantic-index workspace-llm-policy; do mutated="$fixture/$mutation.yaml" node - "$root/backend/package.json" "$workspace" "$mutated" "$mutation" <<'NODE' const fs = require("fs"); @@ -197,11 +182,12 @@ const requireFromBackend = createRequire(process.argv[2]); const yaml = requireFromBackend("yaml"); const [source, destination, mutation] = process.argv.slice(3); const workspace = yaml.parse(fs.readFileSync(source, "utf8")); -if (mutation === "collection-reuse") { - workspace.semantic_index.vector_store.collection = "shared-semantic"; -} else if (mutation === "dimension-change") { - workspace.semantic_index.vector_store.dimensions = 1536; - workspace.semantic_index.embedding.dimensions = 1536; +if (mutation === "workspace-semantic-index") { + workspace.semantic_index = { + vector_store: { engine: "qdrant", collection: "shared-semantic", dimensions: 1536 }, + }; +} else if (mutation === "workspace-llm-policy") { + workspace.llm_policy = { provider: "openai", model: "gpt-test" }; } fs.writeFileSync(destination, yaml.stringify(workspace)); NODE diff --git a/scripts/test-unified-compose.sh b/scripts/test-unified-compose.sh index d74420ad..2d3e3ceb 100755 --- a/scripts/test-unified-compose.sh +++ b/scripts/test-unified-compose.sh @@ -48,11 +48,12 @@ if (!Object.hasOwn(coreEnv, "THT_LLM_URL")) { for (const [key, value] of Object.entries({ THT_INTERNAL_QDRANT_URL: "http://qdrant:6333", THT_INTERNAL_EMBEDDING_URL: "http://embedding:11434", - THT_INTERNAL_EMBEDDING_MODEL: "qwen3-embedding:0.6b", - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024", })) { if (coreEnv[key] !== value) throw new Error(`unexpected core ${key}: ${coreEnv[key]}`); } +for (const key of ["THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "THT_INTERNAL_EMBEDDING_DIMENSIONS"]) { + if (Object.hasOwn(coreEnv, key)) throw new Error(`${key} must come only from the generated installation projection`); +} for (const forbidden of ["THT_VEC_REST_URL", "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL"]) { if (Object.hasOwn(coreEnv, forbidden) && coreEnv[forbidden] !== "") { throw new Error(`core must not require external semantic binding ${forbidden}`); @@ -189,13 +190,14 @@ if (!Object.hasOwn(config.services.core.environment || {}, "THT_LLM_URL")) { for (const [key, value] of Object.entries({ THT_INTERNAL_QDRANT_URL: "http://qdrant:6333", THT_INTERNAL_EMBEDDING_URL: "http://embedding:11434", - THT_INTERNAL_EMBEDDING_MODEL: "qwen3-embedding:0.6b", - THT_INTERNAL_EMBEDDING_DIMENSIONS: "1024", })) { if (config.services.core.environment?.[key] !== value) { throw new Error(`unexpected core ${key}: ${config.services.core.environment?.[key]}`); } } +for (const key of ["THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "THT_INTERNAL_EMBEDDING_DIMENSIONS"]) { + if (Object.hasOwn(config.services.core.environment || {}, key)) throw new Error(`${key} must come only from the generated installation projection`); +} for (const serviceName of ["qdrant", "embedding", "embedding-model-init"]) { if ((config.services[serviceName].ports || []).length !== 0) { throw new Error(`${serviceName} must not publish a host port`); diff --git a/scripts/test-verify-schema-v3-only.sh b/scripts/test-verify-schema-v3-only.sh index 18d134de..27023372 100755 --- a/scripts/test-verify-schema-v3-only.sh +++ b/scripts/test-verify-schema-v3-only.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Regression tests for the fail-closed schema-v3-only absence gate. +# Regression tests for the fail-closed schema-v4-only absence gate. set -euo pipefail project_root="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -25,7 +25,7 @@ fail() { } write_fixture_descriptor() { - local path="$1" workspace_key="${2:-workspace:}" schema_key="${3:- schema_version: 3}" + local path="$1" workspace_key="${2:-workspace:}" schema_key="${3:- schema_version: 4}" printf '%s\n' "$workspace_key" "$schema_key" >"$path" cat >>"$path" <<'YAML' id: fixture-workspace @@ -36,18 +36,6 @@ dwh: database: warehouse schema: public supported_transports: [postgres_direct] -semantic_index: - vector_store: - engine: qdrant - collection: fixture-workspace - dimensions: 1024 - distance: cosine - embedding: - provider: ollama_internal - model: qwen3-embedding:0.6b - dimensions: 1024 -llm_policy: - allowed: [fixture/model] YAML } @@ -62,7 +50,7 @@ seed_fixture() { "$real_git" -C "$fixture" init -q "$real_git" -C "$fixture" config user.email fixture@example.invalid "$real_git" -C "$fixture" config user.name Fixture - printf '%s\n' 'export const schemaVersion = 3;' >"$fixture/backend/src/server.ts" + printf '%s\n' 'export const schemaVersion = 4;' >"$fixture/backend/src/server.ts" printf '%s\n' 'export const spaced = true;' >"$fixture/backend/src/nested dir/file name.ts" newline_path="$fixture/backend/src/line break.ts" @@ -74,7 +62,7 @@ break.ts" printf '%s\n' '#!/usr/bin/env bash' 'echo operator-smoke' >"$fixture/scripts/workspace-registry-smoke.sh" write_fixture_descriptor "$fixture/scripts/fixtures/workspace-registry-smoke.yaml" write_fixture_descriptor "$fixture/scripts/fixtures/workspace-registry-windows.yaml" - printf '%s\n' 'Write-Output "schema v3"' >"$fixture/scripts/test-windows-clone-contract.ps1" + printf '%s\n' 'Write-Output "schema v4"' >"$fixture/scripts/test-windows-clone-contract.ps1" "$real_git" -C "$fixture" add . "$real_git" -C "$fixture" commit -qm seed } @@ -307,14 +295,14 @@ EOF commit_fixture powershell-bundle expect_pass "PowerShell non-workspace bundle" -# Quoted/space/indented YAML keys are real mappings; v3 passes and non-v3 fails. +# Quoted/space/indented YAML keys are real mappings; v4 passes and non-v4 fails. seed_fixture write_fixture_descriptor \ "$fixture/deploy/workspaces/example.yaml" \ '"workspace" :' \ - " 'schema_version' : 3" -commit_fixture quoted-yaml-v3 -expect_pass "quoted and indented workspace v3" + " 'schema_version' : 4" +commit_fixture quoted-yaml-v4 +expect_pass "quoted and indented workspace v4" seed_fixture cat >"$fixture/deploy/workspaces/example.yaml" <<'EOF' @@ -325,7 +313,7 @@ commit_fixture quoted-yaml-noncanonical expect_rejected "quoted workspace noncanonical schema" "deploy/workspaces/example.yaml" seed_fixture -printf '%s\n' 'workspace: { schema_version: 3 }' >"$fixture/deploy/workspaces/example.yaml" +printf '%s\n' 'workspace: { schema_version: 4 }' >"$fixture/deploy/workspaces/example.yaml" commit_fixture inline-workspace expect_rejected "inline workspace mapping" "deploy/workspaces/example.yaml" @@ -388,8 +376,8 @@ printf '%s\n' 'const revision = { revision: { id: "x", state: "operational" } }; commit_fixture revision-object-state expect_rejected "bounded revision object state" "backend/scripts/runtime-check.mjs" -# A top-level workspace descriptor has one exact schema_version: 3 key. -for malformed in schema-v1 schema-v2 leading-zero hexadecimal multiline duplicate; do +# A top-level workspace descriptor has one exact schema_version: 4 key. +for malformed in schema-v1 schema-v2 schema-v3 leading-zero hexadecimal multiline duplicate; do seed_fixture case "$malformed" in schema-v1) @@ -398,17 +386,20 @@ for malformed in schema-v1 schema-v2 leading-zero hexadecimal multiline duplicat schema-v2) printf '%s\n' 'workspace:' ' schema_version: 2' >"$fixture/deploy/workspaces/example.yaml" ;; + schema-v3) + printf '%s\n' 'workspace:' ' schema_version: 3' >"$fixture/deploy/workspaces/example.yaml" + ;; leading-zero) - printf '%s\n' 'workspace:' ' schema_version: 02' >"$fixture/deploy/workspaces/example.yaml" + printf '%s\n' 'workspace:' ' schema_version: 04' >"$fixture/deploy/workspaces/example.yaml" ;; hexadecimal) - printf '%s\n' 'workspace:' ' schema_version: 0x2' >"$fixture/deploy/workspaces/example.yaml" + printf '%s\n' 'workspace:' ' schema_version: 0x4' >"$fixture/deploy/workspaces/example.yaml" ;; multiline) - printf '%s\n' 'workspace:' ' schema_version: >' ' 3' >"$fixture/deploy/workspaces/example.yaml" + printf '%s\n' 'workspace:' ' schema_version: >' ' 4' >"$fixture/deploy/workspaces/example.yaml" ;; duplicate) - printf '%s\n' 'workspace:' ' schema_version: 3' ' schema_version: 3' >"$fixture/deploy/workspaces/example.yaml" + printf '%s\n' 'workspace:' ' schema_version: 4' ' schema_version: 4' >"$fixture/deploy/workspaces/example.yaml" ;; esac commit_fixture "yaml-$malformed" diff --git a/scripts/test_workspace_descriptor_doc_contract.py b/scripts/test_workspace_descriptor_doc_contract.py index dcd6cf86..c68dcf0b 100755 --- a/scripts/test_workspace_descriptor_doc_contract.py +++ b/scripts/test_workspace_descriptor_doc_contract.py @@ -91,8 +91,8 @@ CASES = [ def workspace_region(extra=""): body = ( - "Schema v3 is the only accepted workspace descriptor. " - "Schema v1 and v2 workspace descriptors are rejected before activation." + "Schema v4 is the only accepted workspace descriptor. " + "Schema v1, v2, and v3 workspace descriptors are rejected before activation." ) if extra: body += "\n" + extra @@ -335,16 +335,16 @@ class RegionStructureTests(unittest.TestCase): def test_noncanonical_contract_sentences_are_rejected(self): variants = ( workspace_region().replace( - "Schema v3 is the only accepted workspace descriptor.", - "Only schema v3 workspace descriptors are accepted.", + "Schema v4 is the only accepted workspace descriptor.", + "Only schema v4 workspace descriptors are accepted.", ), workspace_region().replace( - "Schema v3 is the only accepted workspace descriptor.", - "Not Schema v3 is the only accepted workspace descriptor.", + "Schema v4 is the only accepted workspace descriptor.", + "Not Schema v4 is the only accepted workspace descriptor.", ), workspace_region().replace( - "Schema v1 and v2 workspace descriptors are rejected before activation.", - "Not Schema v1 and v2 workspace descriptors are rejected before activation.", + "Schema v1, v2, and v3 workspace descriptors are rejected before activation.", + "Not Schema v1, v2, and v3 workspace descriptors are rejected before activation.", ), ) for index, noncanonical in enumerate(variants): diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index 41c6e0af..eba5caf3 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -317,12 +317,14 @@ task13_compose_files() { -f "$TASK13_ROOT/deploy/compose.server.yaml" -f "$TASK13_ROOT/deploy/compose.session-server.yaml.example" -f "$TASK13_OVERRIDE" + -f "${TASK13_INSTALLATION%/*}/generated/compose.models.yaml" -f "$TASK13_ROOT/deploy/compose.auth-runtime-projection.yaml" ) else TASK13_COMPOSE+=( -f "$TASK13_ROOT/deploy/compose.local.yaml" -f "$TASK13_OVERRIDE" + -f "${TASK13_INSTALLATION%/*}/generated/compose.models.yaml" ) fi if [[ "${TASK13_PROFILE:-local}" == local && -f "$TASK13_CURRENT_IMAGE_OVERRIDE" ]]; then @@ -414,6 +416,100 @@ EOF chmod 0600 "$TASK13_SESSION_CA" } +# Test-only materialization of the same deterministic boundary adapters covered by the Go +# modelprojection tests. Production lifecycle commands always generate these files themselves. +task13_write_model_projection_fixture() { + python3 - "$TASK13_INSTALLATION" "$TASK13_LLM_CONTAINER" <<'PY' +import hashlib +import json +from pathlib import Path +import sys + +installation = Path(sys.argv[1]) +provider_host = sys.argv[2] +generated = installation.parent / "generated" +(generated / "pi").mkdir(parents=True, exist_ok=True) +catalog = { + "schemaVersion": 1, + "defaultSession": "local-qwen/task13-smoke", + "embedding": {"id": "ollama/qwen3-embedding:0.6b", "dimensions": 1024}, + "models": [{ + "id": "local-qwen/task13-smoke", + "provider": "local-qwen", + "model": "task13-smoke", + "label": "Task 13 deterministic smoke", + "upstreamModel": "task13-smoke", + "endpoint": {"baseUrl": f"http://{provider_host}:9000/v1"}, + "authentication": {"mode": "none"}, + "sessionAdapter": {"mode": "openai_compatible"}, + "session": { + "reasoning": False, + "input": ["text"], + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": 4096, + "maxTokens": 64, + }, + }], +} +models = { + "providers": {"local-qwen": { + "baseUrl": f"http://{provider_host}:9000/v1", + "api": "openai-completions", + "apiKey": "local", + "models": [{ + "id": "task13-smoke", "name": "Task 13 deterministic smoke", + "reasoning": False, "input": ["text"], + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": 4096, "maxTokens": 64, + }], + }}, +} +settings = {"defaultProjectTrust": "always", "enabledModels": ["local-qwen/task13-smoke"]} +serialized = [] +for path, value in ( + (generated / "catalog.json", catalog), + (generated / "pi/models.json", models), + (generated / "pi/settings.json", settings), +): + raw = json.dumps(value, indent=2, sort_keys=True) + "\n" + path.write_text(raw) + serialized.append(raw.encode()) +revision = "sha256:" + hashlib.sha256(b"".join(serialized)).hexdigest() +quote = json.dumps +(generated / "compose.models.yaml").write_text(f"""services: + core: + environment: + THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json + THT_MODEL_CATALOG_REVISION: {quote(revision)} + THT_DEFAULT_SESSION_MODEL: local-qwen/task13-smoke + THT_INTERNAL_EMBEDDING_ID: ollama/qwen3-embedding:0.6b + THT_INTERNAL_EMBEDDING_MODEL: qwen3-embedding:0.6b + THT_INTERNAL_EMBEDDING_DIMENSIONS: '1024' + volumes: + - type: bind + source: {quote(str(generated / 'catalog.json'))} + target: /run/thothii-model-catalog/catalog.json + read_only: true + - type: bind + source: {quote(str(generated / 'pi/models.json'))} + target: /home/thoth/.pi/agent/models.json + read_only: true + - type: bind + source: {quote(str(generated / 'pi/settings.json'))} + target: /home/thoth/.pi/agent/settings.json + read_only: true + workspace-maintenance: + environment: + THT_INTERNAL_EMBEDDING_ID: ollama/qwen3-embedding:0.6b + THT_INTERNAL_EMBEDDING_MODEL: qwen3-embedding:0.6b + THT_INTERNAL_EMBEDDING_DIMENSIONS: '1024' + embedding-model-init: + environment: + OLLAMA_MODEL: qwen3-embedding:0.6b +""") +PY +} + task13_write_fixture_files() { printf '{}\n' >"$TASK13_PI_AUTH" printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" @@ -425,41 +521,6 @@ task13_write_fixture_files() { chmod 0700 "$TASK13_AUTH_ROOT" chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" "$TASK13_AUTH_PASSWORD_FILE" - cat >"$TASK13_PI_MODELS" <"$TASK13_PI_SETTINGS" <<'EOF' -{ - "defaultProjectTrust": "always", - "enabledModels": ["local-qwen/task13-smoke"] -} -EOF - chmod 0644 "$TASK13_PI_MODELS" "$TASK13_PI_SETTINGS" - cat >"$TASK13_LLM_SERVER" <<'EOF' import http from "node:http"; @@ -517,8 +578,6 @@ services: labels: io.thothii.task13.run: "$TASK13_RUN_ID" environment: - PI_PROVIDER: local-qwen - PI_MODEL: task13-smoke PI_THINKING: low THT_WORKSPACE_INSTALLATION_ID: task13-smoke THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry @@ -605,15 +664,40 @@ EOF chmod 0600 "$TASK13_OVERRIDE" cat >"$TASK13_INSTALLATION" <"$TASK13_INSTALLATION" < /bin/bash scripts/verify-schema-v3-only.sh --bootstrap-trust-only (cd backend && npm ci --ignore-scripts) (cd backend && npm run build) -(cd backend && npm run test:schema-v3-verifier) +(cd backend && npm run test:schema-v4-verifier) /bin/bash scripts/test-verify-schema-v3-only.sh /bin/bash scripts/verify-schema-v3-only.sh EOF @@ -29,6 +29,6 @@ export NPM_CONFIG_GLOBALCONFIG="$release_tmp/npm-globalconfig" /bin/bash "$root/scripts/verify-schema-v3-only.sh" --bootstrap-trust-only (cd "$root/backend" && npm ci --ignore-scripts) (cd "$root/backend" && npm run build) -(cd "$root/backend" && npm run test:schema-v3-verifier) +(cd "$root/backend" && npm run test:schema-v4-verifier) /bin/bash "$root/scripts/test-verify-schema-v3-only.sh" /bin/bash "$root/scripts/verify-schema-v3-only.sh" diff --git a/scripts/verify-schema-v3-only.sh b/scripts/verify-schema-v3-only.sh index 68b00621..4373bbd5 100755 --- a/scripts/verify-schema-v3-only.sh +++ b/scripts/verify-schema-v3-only.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Fail-closed absence gate for the supported schema-v3-only workspace runtime. +# Fail-closed absence gate for the supported schema-v4-only workspace runtime. set -euo pipefail shopt -s nocasematch @@ -31,7 +31,7 @@ Release trust anchor and order (durable entry point): checkout and performs an inline clean-checkout assertion before the wrapper. scripts/verify-schema-v3-only-release.sh then runs npm ci --ignore-scripts from the trusted backend/package-lock.json; clean build; npm Node verifier test; Bash regression; - and the full schema-v3-only gate, which repeats trust checks. + and the full schema-v4-only gate, which repeats trust checks. EOF } @@ -54,7 +54,7 @@ if ! root="$(cd "$root_argument" 2>/dev/null && pwd -P)"; then fi [[ $runtime_only -eq 1 || $bootstrap_trust_only -eq 1 || "$root" == "$script_root" ]] || { echo "full mode is restricted to the canonical repository" >&2; exit 2; } -tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-v3-gate.XXXXXX")" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-v4-gate.XXXXXX")" trap 'rm -rf "$tmp"' EXIT HUP INT TERM if git_top="$(git -C "$root" rev-parse --show-toplevel 2>"$tmp/rev-parse")"; then :; else @@ -88,6 +88,7 @@ is_allowed_match() { legacy-workspace:scripts/verify-schema-v3-only.sh|legacy-workspace:scripts/test-verify-schema-v3-only.sh|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.mjs|legacy-workspace:backend/scripts/verify-workspace-descriptor-files.test.mjs|legacy-workspace:backend/scripts/revision-state-policy.mjs|legacy-workspace:backend/scripts/revision-state-policy.test.mjs|legacy-workspace:backend/scripts/bash-heredoc.mjs|legacy-workspace:backend/scripts/revision_state_policy.py|legacy-workspace:backend/scripts/test_revision_state_policy.py) return 0 ;; migration-marker:scripts/verify-schema-v3-only.sh|migration-marker:scripts/test-verify-schema-v3-only.sh|migration-marker:backend/scripts/verify-workspace-descriptor-files.mjs|migration-marker:backend/scripts/verify-workspace-descriptor-files.test.mjs|migration-marker:backend/scripts/revision-state-policy.mjs|migration-marker:backend/scripts/revision-state-policy.test.mjs|migration-marker:backend/scripts/bash-heredoc.mjs|migration-marker:backend/scripts/revision_state_policy.py|migration-marker:backend/scripts/test_revision_state_policy.py) return 0 ;; migration-marker:scripts/workspace_descriptor_doc_contract.py|migration-marker:scripts/test_workspace_descriptor_doc_contract.py) return 0 ;; + migration-marker:backend/src/workspaces/schema.ts) return 0 ;; migration-marker:backend/scripts/clean-dist.test.mjs) return 0 ;; *) return 1 ;; esac @@ -204,7 +205,7 @@ bootstrap_files=( ) if [[ $bootstrap_trust_only -eq 1 ]]; then require_trusted_files_at "$root" "${bootstrap_files[@]}" - echo "schema-v3-only bootstrap trust passed" + echo "schema-v4-only bootstrap trust passed" exit 0 fi @@ -272,4 +273,4 @@ if [[ $runtime_only -eq 0 ]]; then --document "$root/docs/operations/workspaces.md" fi -echo "schema-v3-only absence gate passed" +echo "schema-v4-only absence gate passed" diff --git a/scripts/verify-workspace-install-docs.sh b/scripts/verify-workspace-install-docs.sh index 5691b9c6..cedc15a7 100755 --- a/scripts/verify-workspace-install-docs.sh +++ b/scripts/verify-workspace-install-docs.sh @@ -123,8 +123,9 @@ bindings = pathlib.Path(sys.argv[3]).read_text() if local.get("profile") != "local" or server.get("profile") != "server": raise SystemExit("installation examples must retain their local/server profiles") for document in (local, server): - if "metadataGeneration" not in document or "authentication" not in document: - raise SystemExit("installation example lacks metadata or authentication configuration") + catalog = document.get("modelCatalog") + if not isinstance(catalog, dict) or "providers" not in catalog or "authentication" not in document: + raise SystemExit("installation example lacks model catalog or authentication configuration") if re.search(r"(?:KEY|PASSWORD|TOKEN|SECRET)=[^\n#<][^\n]*", bindings): raise SystemExit("workspace bindings example contains a secret value") PY diff --git a/scripts/workspace_descriptor_doc_contract.py b/scripts/workspace_descriptor_doc_contract.py index 1229a3b8..11078f5e 100755 --- a/scripts/workspace_descriptor_doc_contract.py +++ b/scripts/workspace_descriptor_doc_contract.py @@ -14,9 +14,9 @@ from pathlib import Path WORKSPACE_REGION = "workspace-descriptor-contract" NON_WORKSPACE_REGION = "non-workspace-migration" HISTORICAL_ARCHIVE_H1 = "# Historical archive" -CANONICAL_V3_SENTENCE = "Schema v3 is the only accepted workspace descriptor." +CANONICAL_V4_SENTENCE = "Schema v4 is the only accepted workspace descriptor." CANONICAL_REJECTION_SENTENCE = ( - "Schema v1 and v2 workspace descriptors are rejected before activation." + "Schema v1, v2, and v3 workspace descriptors are rejected before activation." ) _MARKER_LINE = re.compile( @@ -34,8 +34,8 @@ _DELETED_TOOL = re.compile( re.IGNORECASE, ) _REMAINING_LEGACY_CLAIM = re.compile( - r"\b(?:schema(?:[- ]v?|\s+version\s*)[12]|" - r"schema_version\s*[:=]\s*[12]|version\s*[12]|v[12]|" + r"\b(?:schema(?:[- ]v?|\s+version\s*)[123]|" + r"schema_version\s*[:=]\s*[123]|version\s*[123]|v[123]|" r"legacy(?:[-\s]+workspace)?[-\s]+descriptors?)\b", re.IGNORECASE, ) @@ -48,9 +48,9 @@ _CODE_RESTORE = str.maketrans( ) _OUTSIDE_LEGACY_REFERENCE = re.compile( - r"\b(?:schema(?:[- ]v?|\s+version\s*)[12]|" - r"schema_version\s*[:=]\s*[12]|" - r"(?:v[12](?:\s*(?:/|and|or)\s*v[12])?)\s+(?:workspace\s+)?descriptors?|" + r"\b(?:schema(?:[- ]v?|\s+version\s*)[123]|" + r"schema_version\s*[:=]\s*[123]|" + r"(?:v[123](?:\s*(?:/|and|or|,)\s*v[123])*)\s+(?:workspace\s+)?descriptors?|" r"legacy(?:[-\s]+workspace)?[-\s]+descriptors?)\b", re.IGNORECASE, ) @@ -355,11 +355,11 @@ def check_document_text(text: str, label: str = "document") -> None: workspace_prose = _render_prose_markdown( scan.prose_text[workspace.start : workspace.end] ) - if _exact_sentence_count(workspace_prose, CANONICAL_V3_SENTENCE) != 1: - raise ContractError(f"{label}: workspace contract lacks the canonical v3-only sentence") + if _exact_sentence_count(workspace_prose, CANONICAL_V4_SENTENCE) != 1: + raise ContractError(f"{label}: workspace contract lacks the canonical v4-only sentence") if _exact_sentence_count(workspace_prose, CANONICAL_REJECTION_SENTENCE) != 1: raise ContractError( - f"{label}: workspace contract lacks the canonical v1/v2 rejection sentence" + f"{label}: workspace contract lacks the canonical v1/v2/v3 rejection sentence" ) workspace_visible = _render_markdown(scan.operator_text[workspace.start : workspace.end]) diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index bd6bfc4d..141220ad 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -2,7 +2,6 @@ package main import ( - "bufio" "context" "encoding/json" "errors" @@ -11,7 +10,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "github.com/aritmolab/thothii/tools/tht/internal/authconfig" @@ -21,6 +19,8 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" + "github.com/aritmolab/thothii/tools/tht/internal/modelmigration" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/project" @@ -42,6 +42,9 @@ descriptor in the current project tree. Commands: setup [--configure-only] [--installation-id ID] [--profile local|server] Create or validate the local non-secret installation configuration. + installation migrate --output PATH --session-default PROVIDER/MODEL + --embedding-id PROVIDER/MODEL --embedding-dimensions N + Create a review-only schema-v2 candidate from all three legacy model sources. version [--json] Show the host CLI build identity. auth configure --mode local|oidc ... Configure local users or OIDC group mapping; see tht auth for exact options. @@ -65,8 +68,6 @@ Commands: pi doctor Check Pi preconditions without changing the installation. pi test Run the temporary Pi/core smoke checks. pi check Alias for pi test. - pi configure [--provider P --model M --thinking low|medium|high] - Select closed backend defaults interactively on a TTY; all flags are required otherwise. pi restart --yes [--drain] Recreate only core with the currently selected Pi image and verify readiness. pi update [--version V] @@ -125,6 +126,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { if command == "version" { return versionCommand(commandArgs, stdout, stderr) } + if command == "installation" { + return installationMigrationCommand(installationPath, commandArgs, stdout, stderr) + } return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) } workingDirectory, err := os.Getwd() @@ -251,7 +255,51 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } func isBootstrapCommand(command string) bool { - return command == "help" || command == "setup" || command == "version" + return command == "help" || command == "setup" || command == "version" || command == "installation" +} + +func installationMigrationCommand(installationPath string, args []string, stdout, stderr io.Writer) int { + if installationPath == "" { + return commandUsageError(stderr, "installation migrate requires --installation with the legacy descriptor") + } + if len(args) == 0 || args[0] != "migrate" { + return commandUsageError(stderr, "installation requires migrate") + } + values := make(map[string]string) + for index := 1; index < len(args); index += 2 { + if index+1 >= len(args) || !strings.HasPrefix(args[index], "--") { + return commandUsageError(stderr, "installation migrate requires flag/value pairs") + } + if _, duplicate := values[args[index]]; duplicate { + return commandUsageError(stderr, args[index]+" may be supplied once") + } + values[args[index]] = args[index+1] + } + for _, required := range []string{"--output", "--session-default", "--embedding-id", "--embedding-dimensions"} { + if values[required] == "" { + return commandUsageError(stderr, "installation migrate requires "+required) + } + } + if len(values) != 4 { + return commandUsageError(stderr, "installation migrate received an unknown option") + } + dimensions, err := modelmigration.ParseDimensions(values["--embedding-dimensions"]) + if err != nil { + return commandUsageError(stderr, err.Error()) + } + request := modelmigration.Request{ + InstallationPath: installationPath, + OutputPath: values["--output"], + SessionDefault: values["--session-default"], + EmbeddingID: values["--embedding-id"], + EmbeddingDimensions: dimensions, + } + if err := modelmigration.Run(request); err != nil { + fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil)) + return 2 + } + fmt.Fprintf(stdout, "Installation schema-v2 candidate written to %s. Legacy inputs were not changed.\n", request.OutputPath) + return 0 } type setupExecutor func(context.Context, compose.Runner, setup.Request, io.Reader, io.Writer) (setup.Result, error) @@ -479,6 +527,11 @@ func piCommand(ctx context.Context, installation config.Installation, runner com } defer func() { _ = lock.Release() }() } + if args[0] == "restart" || args[0] == "update" || args[0] == "rollback" { + if err := modelprojection.Generate(installation); err != nil { + return commandUsageError(stderr, "installation model catalog could not be projected: "+err.Error()) + } + } controlled := compose.InstallationRunner{Installation: installation, Runner: runner} switch args[0] { case "status": @@ -516,20 +569,6 @@ func piCommand(ctx context.Context, installation config.Installation, runner com logArgs := []string{"logs", "--tail", "200", "core"} result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil) return writeResult(result, err, secretValues, stdout, stderr) - case "configure": - authFile, authErr := installation.EnvironmentValue("PI_AUTH_FILE") - if authErr != nil || strings.TrimSpace(authFile) == "" { - return commandUsageError(stderr, "PI_AUTH_FILE must name the actual protected host credential file") - } - defaults, err := resolvePiConfigure(ctx, controlled, args[1:], os.Stdin, stdout, stdinIsTTY(os.Stdin)) - if err != nil { - return commandUsageError(stderr, err.Error()) - } - if err := pi.Configure(ctx, controlled, defaults); err != nil { - return piFailure(stderr, err, secretValues) - } - fmt.Fprintf(stdout, "Pi defaults applied and read back. Provider credentials remain only in the host file %s (mode 0600). Never pass credentials to tht.\n", authFile) - return 0 case "restart": request, err := parsePiRestartArgs( args[1:], @@ -617,110 +656,6 @@ func piCommand(ctx context.Context, installation config.Installation, runner com } } -func resolvePiConfigure( - ctx context.Context, - runner pi.Runner, - args []string, - input io.Reader, - prompt io.Writer, - isTTY bool, -) (pi.Defaults, error) { - if len(args) > 0 { - return parsePiConfigureArgs(args) - } - if !isTTY { - return pi.Defaults{}, errors.New("non-interactive pi configure requires --provider --model --thinking") - } - options, err := pi.ConfigurationOptions(ctx, runner) - if err != nil { - return pi.Defaults{}, err - } - providers := uniqueProviders(options) - scanner := bufio.NewScanner(input) - provider, err := numberedChoice(scanner, prompt, "provider", providers) - if err != nil { - return pi.Defaults{}, err - } - models := make([]string, 0) - for _, option := range options { - if option.Provider == provider { - models = append(models, option.ID) - } - } - model, err := numberedChoice(scanner, prompt, "model", models) - if err != nil { - return pi.Defaults{}, err - } - thinking, err := numberedChoice(scanner, prompt, "thinking level", []string{"low", "medium", "high"}) - if err != nil { - return pi.Defaults{}, err - } - return pi.Defaults{Provider: provider, Model: model, Thinking: thinking}, nil -} - -func uniqueProviders(options []pi.ModelOption) []string { - seen := make(map[string]bool) - providers := make([]string, 0) - for _, option := range options { - if !seen[option.Provider] { - seen[option.Provider] = true - providers = append(providers, option.Provider) - } - } - return providers -} - -func numberedChoice(scanner *bufio.Scanner, output io.Writer, label string, choices []string) (string, error) { - if len(choices) == 0 { - return "", fmt.Errorf("Pi returned no %s choices", label) - } - fmt.Fprintf(output, "Select %s:\n", label) - for index, choice := range choices { - fmt.Fprintf(output, " %d) %s\n", index+1, choice) - } - for { - fmt.Fprintf(output, "Choice [1-%d]: ", len(choices)) - if !scanner.Scan() { - return "", fmt.Errorf("interactive %s selection ended before a choice was entered", label) - } - selected, err := strconv.Atoi(strings.TrimSpace(scanner.Text())) - if err == nil && selected >= 1 && selected <= len(choices) { - return choices[selected-1], nil - } - fmt.Fprintln(output, "Enter one of the listed numbers.") - } -} - -func stdinIsTTY(input *os.File) bool { - info, err := input.Stat() - return err == nil && info.Mode()&os.ModeCharDevice != 0 -} - -func parsePiConfigureArgs(args []string) (pi.Defaults, error) { - var value pi.Defaults - for len(args) > 0 { - if len(args) < 2 { - return pi.Defaults{}, errors.New("configure options require values") - } - key, v := args[0], args[1] - args = args[2:] - switch key { - case "--provider": - value.Provider = v - case "--model": - value.Model = v - case "--thinking": - value.Thinking = v - default: - return pi.Defaults{}, fmt.Errorf("unknown pi configure option %q", key) - } - } - if value.Provider == "" || value.Model == "" || value.Thinking == "" { - return pi.Defaults{}, errors.New("pi configure requires --provider --model --thinking; THT_LLM_URL stays Compose-managed") - } - return value, nil -} - func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) { request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath} sourceSpecified := false @@ -1008,7 +943,7 @@ func piMutationRequiresLifecycleLock(args []string) bool { return false } switch args[0] { - case "configure", "restart", "update", "rollback": + case "restart", "update", "rollback": return true case "maintenance": return len(args) > 1 && args[1] == "recover" diff --git a/tools/tht/cmd/tht/main_task6_test.go b/tools/tht/cmd/tht/main_task6_test.go index 5524f401..4278ee58 100644 --- a/tools/tht/cmd/tht/main_task6_test.go +++ b/tools/tht/cmd/tht/main_task6_test.go @@ -46,6 +46,7 @@ func TestVersionCommandIsDescriptorFreeAndMachineReadable(t *testing.T) { func TestDoctorJSONWritesOnlyOneReportDocument(t *testing.T) { fixture := newCLIFixture(t, "SAFE_VALUE=1\n") fixture.setEnvironment(t) + fixture.generateModelProjection(t) fullHealth := `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]` t.Setenv("THT_FAKE_PS", fullHealth) diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index 6ebcb978..2b89fce3 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -17,6 +17,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/setup" "github.com/aritmolab/thothii/tools/tht/internal/testsupport" @@ -175,7 +176,6 @@ func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) { {args: []string{"doctor"}, want: false}, {args: []string{"test"}, want: false}, {args: []string{"logs"}, want: false}, - {args: []string{"configure"}, want: true}, {args: []string{"restart"}, want: true}, {args: []string{"update"}, want: true}, {args: []string{"rollback"}, want: true}, @@ -322,39 +322,6 @@ func TestSetupCommandBuildsAndStartsUnlessConfigureOnlyIsRequested(t *testing.T) } } -// Catches interactive configuration prompts that use retired model-only data instead of the -// provider, model, and reasoning choices supplied by the dedicated Pi Management API. -func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) { - runner := &wizardRunner{} - var prompt bytes.Buffer - defaults, err := resolvePiConfigure( - context.Background(), runner, nil, strings.NewReader("2\n1\n3\n"), &prompt, true, - ) - if err != nil { - t.Fatal(err) - } - want := pi.Defaults{Provider: "zai", Model: "glm-5.2", Thinking: "high"} - if defaults != want { - t.Fatalf("defaults = %#v", defaults) - } - for _, expected := range []string{"1) deepseek", "2) zai", "1) glm-5.2", "3) high"} { - if !strings.Contains(prompt.String(), expected) { - t.Errorf("prompt %q missing %q", prompt.String(), expected) - } - } -} - -func TestResolvePiConfigureRequiresExplicitFlagsWithoutTTY(t *testing.T) { - runner := &wizardRunner{} - _, err := resolvePiConfigure(context.Background(), runner, nil, strings.NewReader("1\n1\n1\n"), io.Discard, false) - if err == nil || !strings.Contains(err.Error(), "non-interactive") { - t.Fatalf("resolvePiConfigure() error = %v, want explicit non-interactive guidance", err) - } - if len(runner.calls) != 0 { - t.Fatalf("Docker calls = %v, want none", runner.calls) - } -} - func TestPiLifecycleContractErrorsExitTwo(t *testing.T) { for _, lifecycleErr := range []error{pi.ErrActiveSessions, pi.ErrInterruptedUpdate} { var stderr bytes.Buffer @@ -421,12 +388,11 @@ func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) { } } -func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) { +func TestUsageDocumentsUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) { if strings.Contains(usage, "--follow") { t.Fatal("usage still advertises unbounded log following") } for _, required := range []string{ - "--provider P --model M --thinking low|medium|high", "--source build", "--source pull --image IMAGE@sha256:DIGEST", "pi restart --yes [--drain]", @@ -610,13 +576,6 @@ func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing. assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend") } -type wizardRunner struct{ calls []string } - -func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { - r.calls = append(r.calls, strings.Join(args, " ")) - return compose.Result{Stdout: `{"providers":["deepseek","zai"],"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}],"reasoning":["low","medium","high"]}`}, nil -} - func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) { fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n") secretPath := filepath.Join(fixture.root, "operator-secret") @@ -941,6 +900,7 @@ func TestRunStatusUsesStableComposeArguments(t *testing.T) { "--env-file", fixture.envFile, "-f", filepath.Join(fixture.projectDirectory, "compose.yaml"), "-f", filepath.Join(fixture.projectDirectory, "deploy", "compose.local.yaml"), + "-f", filepath.Join(fixture.root, "generated", "compose.models.yaml"), "ps", "--format", "json", } got := invocations[0] @@ -998,6 +958,7 @@ func TestRunExplainsWhenDockerIsNotAvailable(t *testing.T) { func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) { fixture := newCLIFixture(t, "SAFE_VALUE=1\n") fixture.setEnvironment(t) + fixture.generateModelProjection(t) report := runDoctorJSON(t, fixture) assertDoctorCheck(t, report, "configuration", doctor.StatusPassed) @@ -1007,6 +968,7 @@ func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) { func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) { fixture := newCLIFixture(t, "SAFE_VALUE=1\n") fixture.setEnvironment(t) + fixture.generateModelProjection(t) testsupport.SymlinkOrSkip( t, filepath.Join(fixture.projectDirectory, "missing-workspace.yaml"), @@ -1021,6 +983,7 @@ func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) { fixture := newCLIFixture(t, "SAFE_VALUE=1\n") fixture.setEnvironment(t) + fixture.generateModelProjection(t) t.Setenv( "THT_FAKE_PS", "{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+ @@ -1189,31 +1152,6 @@ func TestRunPiUpdateAcceptsExplicitVersionWithoutAdvancedFlags(t *testing.T) { } } -func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) { - fixture := newCLIFixture(t, "") - authFile := filepath.Join(fixture.root, "pi-auth.json") - if err := os.WriteFile(authFile, []byte(`{"provider":"credential"}`), 0o600); err != nil { - t.Fatal(err) - } - fixture.setEnvContents(t, "THT_LLM_URL=https://llm.example.invalid\nPI_AUTH_FILE="+authFile+"\n") - - var stdout, stderr bytes.Buffer - exitCode := run(context.Background(), []string{ - "--installation", fixture.installationPath, "pi", "configure", - "--provider", "provider", "--model", "model", "--thinking", "medium", - }, &stdout, &stderr) - - if exitCode != 0 { - t.Fatalf("run() exit = %d, stderr=%s", exitCode, stderr.String()) - } - if !strings.Contains(stdout.String(), authFile) { - t.Fatalf("stdout = %q, want host auth path", stdout.String()) - } - if strings.Contains(stdout.String(), "/home/thoth/.pi") { - t.Fatalf("stdout exposed container-only auth path: %q", stdout.String()) - } -} - func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) { fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture.setEnvironment(t) @@ -1493,7 +1431,9 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture { t.Fatal(err) } installationPath := filepath.Join(root, "thothii-installation.yaml") - contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" + contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\n" + + "modelCatalog:\n defaults:\n session: deepseek/deepseek-v4-pro\n embedding:\n id: ollama/qwen3-embedding:0.6b\n dimensions: 1024\n providers:\n deepseek:\n authentication: {mode: pi_auth}\n session: {mode: pi_builtin}\n models:\n deepseek-v4-pro:\n session: {}\n" + + "authentication:\n configDirectory: " + authDirectory + "\n" if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } @@ -1589,13 +1529,26 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) { t.Setenv("THT_FAKE_PI_VERSION", "0.80.3") } +func (f cliFixture) generateModelProjection(t *testing.T) { + t.Helper() + installation, err := config.Load(f.installationPath) + if err != nil { + t.Fatal(err) + } + if err := modelprojection.Generate(installation); err != nil { + t.Fatal(err) + } +} + func (f cliFixture) setProfile(t *testing.T, profile string) { t.Helper() composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml") if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } - contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n" + contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n" + + "modelCatalog:\n defaults:\n session: deepseek/deepseek-v4-pro\n embedding:\n id: ollama/qwen3-embedding:0.6b\n dimensions: 1024\n providers:\n deepseek:\n authentication: {mode: pi_auth}\n session: {mode: pi_builtin}\n models:\n deepseek-v4-pro:\n session: {}\n" + + "authentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n" if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } diff --git a/tools/tht/internal/backup/create.go b/tools/tht/internal/backup/create.go index 1f1cdd95..8f0f77c3 100644 --- a/tools/tht/internal/backup/create.go +++ b/tools/tht/internal/backup/create.go @@ -21,6 +21,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/service" "gopkg.in/yaml.v3" @@ -734,6 +735,11 @@ func activeSessions(value string) (bool, error) { } func composeStartAndVerify(ctx context.Context, installation config.Installation, runner archiveRunner) error { + // Projections are disposable and deliberately excluded from archives. Regenerate them from + // the restored installation catalog immediately before any service is started. + if err := modelprojection.Generate(installation); err != nil { + return fmt.Errorf("regenerate model projections: %w", err) + } if err := runCompose(ctx, installation, runner, "start"); err != nil { return err } @@ -949,8 +955,6 @@ func configurationInputs(installation config.Installation) []configurationInput inputs := []configurationInput{ {"configuration/installation/thothii-installation.yaml", "installation", installation.Path, false}, {"configuration/environment/operator.env", "installation", installation.EnvFile, false}, - {"configuration/pi/models.json", "pi", filepath.Join(installation.ProjectDirectory, "deploy", "pi", "models.json"), false}, - {"configuration/pi/settings.json", "pi", filepath.Join(installation.ProjectDirectory, "deploy", "pi", "settings.json"), false}, {"configuration/generated/current-image.yaml", "installation", installation.CurrentImageOverridePath(), true}, } for index, source := range installation.Overrides { diff --git a/tools/tht/internal/backup/create_test.go b/tools/tht/internal/backup/create_test.go index 0a1b8d5c..7e7254e3 100644 --- a/tools/tht/internal/backup/create_test.go +++ b/tools/tht/internal/backup/create_test.go @@ -143,8 +143,6 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes for _, path := range []string{ "configuration/installation/thothii-installation.yaml", "configuration/environment/operator.env", - "configuration/pi/models.json", - "configuration/pi/settings.json", "configuration/generated/current-image.yaml", } { if _, exists := archive.files[path]; !exists { diff --git a/tools/tht/internal/backup/restore_host.go b/tools/tht/internal/backup/restore_host.go index de262993..ef0490fb 100644 --- a/tools/tht/internal/backup/restore_host.go +++ b/tools/tht/internal/backup/restore_host.go @@ -276,10 +276,6 @@ func restoreFileTarget(installation config.Installation, entry ArchiveEntryMetad return installation.Path, nil case "configuration/environment/operator.env": return installation.EnvFile, nil - case "configuration/pi/models.json": - return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "models.json"), nil - case "configuration/pi/settings.json": - return filepath.Join(installation.ProjectDirectory, "deploy", "pi", "settings.json"), nil case "configuration/generated/current-image.yaml": return installation.CurrentImageOverridePath(), nil } diff --git a/tools/tht/internal/config/discovery_test.go b/tools/tht/internal/config/discovery_test.go index 9424cc75..16841555 100644 --- a/tools/tht/internal/config/discovery_test.go +++ b/tools/tht/internal/config/discovery_test.go @@ -199,7 +199,7 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string) t.Fatal(err) } installationPath := filepath.Join(directory, "thothii-installation.yaml") - contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" + contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" + minimalModelCatalogYAML() if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } diff --git a/tools/tht/internal/config/installation.go b/tools/tht/internal/config/installation.go index dbd76acd..a3c9232f 100644 --- a/tools/tht/internal/config/installation.go +++ b/tools/tht/internal/config/installation.go @@ -28,46 +28,21 @@ const maxEnvironmentFileBytes = 1 << 20 const maxMetadataSecretBundleBytes = 64 << 10 -const maxMetadataGenerationModels = 64 - const maxSecretSources = 32 var dotenvParseMu sync.Mutex type descriptor struct { + SchemaVersion int `yaml:"schemaVersion"` Profile string `yaml:"profile"` ProjectDirectory string `yaml:"projectDirectory"` EnvFile string `yaml:"envFile"` WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"` Authentication authenticationDescriptor `yaml:"authentication"` - MetadataGeneration metadataGenerationDescriptor `yaml:"metadataGeneration"` + ModelCatalog ModelCatalog `yaml:"modelCatalog"` Overrides []string `yaml:"overrides"` } -type metadataGenerationDescriptor struct { - Default string `yaml:"default"` - Models []metadataGenerationModelDescriptor `yaml:"models"` -} - -type metadataGenerationModelDescriptor struct { - ID string `yaml:"id"` - Label string `yaml:"label"` - LiteLLM liteLLMDescriptor `yaml:"litellm"` - APIKeyEnv string `yaml:"apiKeyEnv"` -} - -type liteLLMDescriptor struct { - Provider string `yaml:"provider"` - Model string `yaml:"model"` - DisableThinking bool `yaml:"disableThinking"` - Endpoint *metadataEndpointDescriptor `yaml:"endpoint"` -} - -type metadataEndpointDescriptor struct { - BaseURL string `yaml:"baseUrl"` - APIVersion string `yaml:"apiVersion"` -} - type authenticationDescriptor struct { ConfigDirectory string `yaml:"configDirectory"` RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"` @@ -105,46 +80,17 @@ type Authentication struct { RuntimeProjection *RuntimeProjection } -// MetadataGenerationEndpoint contains optional provider endpoint settings for one LiteLLM model. -type MetadataGenerationEndpoint struct { - BaseURL string - APIVersion string -} - -// MetadataGenerationLiteLLM identifies the provider/model pair used by the internal completion helper. -type MetadataGenerationLiteLLM struct { - Provider string - Model string - DisableThinking bool - Endpoint *MetadataGenerationEndpoint -} - -// MetadataGenerationModel is one selectable installation-owned metadata-generation model. -// APIKeyEnv is an optional reference only; credential values never enter Installation. -// An empty value is allowed only for a model with an explicit keyless endpoint. -type MetadataGenerationModel struct { - ID string - Label string - LiteLLM MetadataGenerationLiteLLM - APIKeyEnv string -} - -// MetadataGeneration is the installation-owned model list and its default selection. -type MetadataGeneration struct { - Default string - Models []MetadataGenerationModel -} - // Installation is a validated local Compose installation. It intentionally contains paths, not // environment values or secret content. type Installation struct { Path string + SchemaVersion int Profile string ProjectDirectory string EnvFile string WorkspaceRepository WorkspaceRepository Authentication Authentication - MetadataGeneration MetadataGeneration + ModelCatalog ModelCatalog Overrides []string } @@ -171,18 +117,34 @@ func Load(path string) (Installation, error) { decoder := yaml.NewDecoder(file) decoder.KnownFields(true) if err := decoder.Decode(&raw); err != nil { + if strings.Contains(err.Error(), "field metadataGeneration not found") { + return Installation{}, errors.New("migration_required: metadataGeneration was replaced by modelCatalog in installation schema version 2") + } return Installation{}, fmt.Errorf("read installation file: %w", err) } if err := ensureOnlyOneDocument(decoder); err != nil { return Installation{}, err } + if raw.SchemaVersion != 2 { + return Installation{}, errors.New("migration_required: installation schemaVersion must be 2") + } if raw.Profile != "local" && raw.Profile != "server" { return Installation{}, fmt.Errorf("profile must be local or server") } if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil { return Installation{}, err } + for _, legacyProjection := range []string{ + filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json"), + filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json"), + } { + if _, err := os.Lstat(legacyProjection); err == nil { + return Installation{}, errors.New("migration_required: remove legacy deploy/pi model sources after reviewing the schema version 2 candidate") + } else if !errors.Is(err, os.ErrNotExist) { + return Installation{}, errors.New("legacy deploy/pi model sources could not be inspected") + } + } if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil { return Installation{}, err } @@ -198,31 +160,9 @@ func Load(path string) (Installation, error) { GID: raw.Authentication.RuntimeProjection.GID, } } - metadataGeneration := MetadataGeneration{ - Default: raw.MetadataGeneration.Default, - Models: make([]MetadataGenerationModel, 0, len(raw.MetadataGeneration.Models)), - } - for _, rawModel := range raw.MetadataGeneration.Models { - model := MetadataGenerationModel{ - ID: rawModel.ID, - Label: rawModel.Label, - LiteLLM: MetadataGenerationLiteLLM{ - Provider: rawModel.LiteLLM.Provider, - Model: rawModel.LiteLLM.Model, - DisableThinking: rawModel.LiteLLM.DisableThinking, - }, - APIKeyEnv: rawModel.APIKeyEnv, - } - if rawModel.LiteLLM.Endpoint != nil { - model.LiteLLM.Endpoint = &MetadataGenerationEndpoint{ - BaseURL: rawModel.LiteLLM.Endpoint.BaseURL, - APIVersion: rawModel.LiteLLM.Endpoint.APIVersion, - } - } - metadataGeneration.Models = append(metadataGeneration.Models, model) - } installation := Installation{ Path: path, + SchemaVersion: raw.SchemaVersion, Profile: raw.Profile, ProjectDirectory: filepath.Clean(raw.ProjectDirectory), EnvFile: filepath.Clean(raw.EnvFile), @@ -231,15 +171,15 @@ func Load(path string) (Installation, error) { Branch: raw.WorkspaceRepository.Branch, Access: raw.WorkspaceRepository.Access, }, - Authentication: authentication, - MetadataGeneration: metadataGeneration, - Overrides: make([]string, 0, len(raw.Overrides)), + Authentication: authentication, + ModelCatalog: raw.ModelCatalog, + Overrides: make([]string, 0, len(raw.Overrides)), } values, err := installation.environmentValues() if err != nil { return Installation{}, errors.New("installation secret declarations could not be read") } - if err := installation.validateMetadataGeneration(values); err != nil { + if err := installation.ModelCatalog.Validate(values); err != nil { return Installation{}, err } if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() { @@ -277,88 +217,9 @@ func Load(path string) (Installation, error) { return installation, nil } -func (i Installation) validateMetadataGeneration(values map[string]string) error { - if len(i.MetadataGeneration.Models) > maxMetadataGenerationModels { - return fmt.Errorf( - "metadataGeneration.models must contain at most %d entries", - maxMetadataGenerationModels, - ) - } - seen := make(map[string]struct{}, len(i.MetadataGeneration.Models)) - for index, model := range i.MetadataGeneration.Models { - if err := validateMetadataGenerationModel(index, model); err != nil { - return err - } - if _, exists := seen[model.ID]; exists { - return fmt.Errorf("duplicate metadataGeneration model id %q", model.ID) - } - seen[model.ID] = struct{}{} - } - if len(i.MetadataGeneration.Models) > 0 && i.MetadataGeneration.Default == "" { - return errors.New("metadataGeneration.default is required when models are configured") - } - if i.MetadataGeneration.Default != "" { - if _, exists := seen[i.MetadataGeneration.Default]; !exists { - return fmt.Errorf( - "metadataGeneration.default %q does not identify a configured model", - i.MetadataGeneration.Default, - ) - } - } - if len(i.MetadataGeneration.Models) == 0 { - return nil - } - if values["THT_INSTALLATION_CONFIG_SOURCE"] != i.Path { - return errors.New("metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file") - } - requiresSecrets := false - for _, model := range i.MetadataGeneration.Models { - if model.APIKeyEnv != "" { - requiresSecrets = true - break - } - } - secrets := map[string]string{} - if requiresSecrets { - bundlePath := values["THT_SECRETS_FILE"] - if bundlePath == "" { - return errors.New("metadataGeneration keyed models require THT_SECRETS_FILE") - } - var err error - secrets, err = readMetadataGenerationSecrets(bundlePath) - if err != nil { - return err - } - } - for _, model := range i.MetadataGeneration.Models { - if model.APIKeyEnv == "" { - continue - } - value, exists := secrets[model.APIKeyEnv] - if !exists { - return fmt.Errorf( - "metadataGeneration model %q secret %q is missing from THT_SECRETS_FILE", - model.ID, - model.APIKeyEnv, - ) - } - if !usableMetadataGenerationSecret(value) { - return fmt.Errorf( - "metadataGeneration model %q secret %q is unusable", - model.ID, - model.APIKeyEnv, - ) - } - } - return nil -} - -var metadataModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) -var metadataProviderPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) -var metadataProviderModelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`) -var metadataAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) var metadataSecretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`) var metadataAPIKeyEnvironments = map[string]struct{}{ + "THT_MODEL_API_KEY": {}, "THT_METADATA_API_KEY": {}, "ANTHROPIC_API_KEY": {}, "AZURE_API_KEY": {}, @@ -392,50 +253,6 @@ var metadataSecretBundleKeys = map[string]struct{}{ "ZAI_API_KEY": {}, } -func validateMetadataGenerationModel(index int, model MetadataGenerationModel) error { - prefix := fmt.Sprintf("metadataGeneration.models[%d]", index) - if !metadataModelIDPattern.MatchString(model.ID) { - return fmt.Errorf("%s.id is invalid", prefix) - } - if len(model.Label) == 0 || len(model.Label) > 128 || strings.TrimSpace(model.Label) != model.Label || - strings.IndexFunc(model.Label, unicode.IsControl) >= 0 { - return fmt.Errorf("%s.label is invalid", prefix) - } - if !metadataProviderPattern.MatchString(model.LiteLLM.Provider) { - return fmt.Errorf("%s.litellm.provider is invalid", prefix) - } - if !metadataProviderModelPattern.MatchString(model.LiteLLM.Model) { - return fmt.Errorf("%s.litellm.model is invalid", prefix) - } - if model.APIKeyEnv == "" { - if model.LiteLLM.Endpoint == nil { - return fmt.Errorf("%s.apiKeyEnv is required unless an explicit keyless endpoint is configured", prefix) - } - } else { - if !metadataSecretBundleKeyPattern.MatchString(model.APIKeyEnv) { - return fmt.Errorf("%s.apiKeyEnv is invalid", prefix) - } - if _, allowed := metadataAPIKeyEnvironments[model.APIKeyEnv]; !allowed { - return fmt.Errorf("%s.apiKeyEnv is invalid", prefix) - } - } - if endpoint := model.LiteLLM.Endpoint; endpoint != nil { - parsed, err := url.Parse(endpoint.BaseURL) - if err != nil || strings.TrimSpace(endpoint.BaseURL) != endpoint.BaseURL || - (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || - parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("%s.litellm.endpoint.baseUrl is invalid", prefix) - } - if endpoint.APIVersion != "" && !metadataAPIVersionPattern.MatchString(endpoint.APIVersion) { - return fmt.Errorf("%s.litellm.endpoint.apiVersion is invalid", prefix) - } - } - if model.LiteLLM.DisableThinking && model.LiteLLM.Endpoint == nil { - return fmt.Errorf("%s.litellm.disableThinking requires an explicit endpoint", prefix) - } - return nil -} - func readMetadataGenerationSecrets(path string) (map[string]string, error) { contents, err := safeio.ReadCanonicalPrivateRegular(path, maxMetadataSecretBundleBytes) if err != nil { @@ -604,6 +421,7 @@ func (i Installation) ComposeFiles() []string { filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"), } files = append(files, i.Overrides...) + files = append(files, i.ModelProjectionComposePath()) if i.HasRuntimeAuthProjection() { files = append(files, i.runtimeAuthProjectionComposePath()) } @@ -614,6 +432,27 @@ func (i Installation) ComposeFiles() []string { return files } +// GeneratedDirectory contains disposable runtime adapters derived from modelCatalog. +func (i Installation) GeneratedDirectory() string { + return filepath.Join(filepath.Dir(i.Path), "generated") +} + +func (i Installation) GeneratedModelCatalogPath() string { + return filepath.Join(i.GeneratedDirectory(), "catalog.json") +} + +func (i Installation) GeneratedPiModelsPath() string { + return filepath.Join(i.GeneratedDirectory(), "pi", "models.json") +} + +func (i Installation) GeneratedPiSettingsPath() string { + return filepath.Join(i.GeneratedDirectory(), "pi", "settings.json") +} + +func (i Installation) ModelProjectionComposePath() string { + return filepath.Join(i.GeneratedDirectory(), "compose.models.yaml") +} + func (i Installation) runtimeAuthProjectionComposePath() string { return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml") } diff --git a/tools/tht/internal/config/installation_metadata_generation_test.go b/tools/tht/internal/config/installation_metadata_generation_test.go deleted file mode 100644 index 39941d1f..00000000 --- a/tools/tht/internal/config/installation_metadata_generation_test.go +++ /dev/null @@ -1,458 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -func TestLoadAcceptsMetadataGenerationModels(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, strings.Join([]string{ - "THT_SECRETS_FILE=" + strconv.Quote(secretBundle), - "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath), - }, "\n")+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: - provider: openai - model: gpt-4.1-mini - endpoint: - baseUrl: https://api.openai.example/v1 - apiVersion: "2026-08-01" - apiKeyEnv: OPENAI_API_KEY -`) - - installation, err := Load(installationPath) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if installation.MetadataGeneration.Default != "openai-mini" { - t.Fatalf("metadata default = %q", installation.MetadataGeneration.Default) - } - if len(installation.MetadataGeneration.Models) != 1 { - t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models) - } - model := installation.MetadataGeneration.Models[0] - if model.ID != "openai-mini" || model.Label != "OpenAI Mini" || - model.LiteLLM.Provider != "openai" || model.LiteLLM.Model != "gpt-4.1-mini" || - model.LiteLLM.Endpoint == nil || model.LiteLLM.Endpoint.BaseURL != "https://api.openai.example/v1" || - model.LiteLLM.Endpoint.APIVersion != "2026-08-01" || model.APIKeyEnv != "OPENAI_API_KEY" { - t.Fatalf("metadata model = %#v", model) - } - if strings.Contains(strings.TrimSpace(model.APIKeyEnv), "provider-secret") { - t.Fatal("installation model exposed the credential value") - } -} - -func TestLoadAcceptsMixedKeyedAndKeylessMetadataGenerationModels(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile( - secretBundle, - []byte("DEEPSEEK_API_KEY=deepseek-secret\nZAI_API_KEY=zai-secret\n"), - 0o600, - ); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: glm-53 - models: - - id: deepseek-v4-pro - label: DeepSeek V4 Pro - litellm: {provider: deepseek, model: deepseek-v4-pro} - apiKeyEnv: DEEPSEEK_API_KEY - - id: deepseek-v4-flash - label: DeepSeek V4 Flash - litellm: {provider: deepseek, model: deepseek-v4-flash} - apiKeyEnv: DEEPSEEK_API_KEY - - id: glm-53 - label: GLM 5.3 - litellm: - provider: openai - model: glm-5.3 - endpoint: {baseUrl: https://api.z.ai/api/coding/paas/v4} - apiKeyEnv: ZAI_API_KEY - - id: qwen-36 - label: Qwen 3.6 - litellm: - provider: openai - model: qwen3.6-35b-a3b - disableThinking: true - endpoint: {baseUrl: https://models.internal.example/v1} -`) - - installation, err := Load(installationPath) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if len(installation.MetadataGeneration.Models) != 4 { - t.Fatalf("metadata models = %#v", installation.MetadataGeneration.Models) - } - qwen := installation.MetadataGeneration.Models[3] - if qwen.APIKeyEnv != "" || !qwen.LiteLLM.DisableThinking || qwen.LiteLLM.Endpoint == nil { - t.Fatalf("keyless qwen model = %#v", qwen) - } -} - -func TestLoadAcceptsOnlyExplicitKeylessMetadataGenerationModelWithoutBundle(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - appendFile(t, envFile, "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: qwen-36 - models: - - id: qwen-36 - label: Qwen 3.6 - litellm: - provider: openai - model: qwen3.6-35b-a3b - disableThinking: true - endpoint: {baseUrl: https://models.internal.example/v1} -`) - - if _, err := Load(installationPath); err != nil { - t.Fatalf("Load() error = %v", err) - } -} - -func TestLoadRejectsTooManyMetadataGenerationModels(t *testing.T) { - installationPath, _, _, _ := writeInstallation(t, "local") - var configuration strings.Builder - configuration.WriteString("metadataGeneration:\n default: model-0\n models:\n") - for index := 0; index <= maxMetadataGenerationModels; index++ { - fmt.Fprintf(&configuration, ` - id: model-%d - label: Model %d - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`, index, index) - } - appendFile(t, installationPath, configuration.String()) - - _, err := Load(installationPath) - want := fmt.Sprintf( - "metadataGeneration.models must contain at most %d entries", - maxMetadataGenerationModels, - ) - if err == nil || !strings.Contains(err.Error(), want) { - t.Fatalf("Load() error = %v, want %q", err, want) - } -} - -func TestLoadRejectsDuplicateMetadataGenerationModelIDs(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY - - id: openai-mini - label: Duplicate - litellm: {provider: openai, model: gpt-4.1} - apiKeyEnv: OPENAI_API_KEY -`) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), `duplicate metadataGeneration model id "openai-mini"`) { - t.Fatalf("Load() error = %v, want actionable duplicate-id error", err) - } -} - -func TestLoadRejectsMissingOrUnknownMetadataGenerationDefault(t *testing.T) { - for _, test := range []struct { - name string - defaultYAML string - want string - }{ - { - name: "missing", - want: "metadataGeneration.default is required when models are configured", - }, - { - name: "unknown", - defaultYAML: " default: unavailable\n", - want: `metadataGeneration.default "unavailable" does not identify a configured model`, - }, - } { - t.Run(test.name, func(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, "metadataGeneration:\n"+test.defaultYAML+` models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("Load() error = %v, want %q", err, test.want) - } - }) - } -} - -func TestLoadRejectsMalformedMetadataGenerationModelSettings(t *testing.T) { - validPrefix := ` - id: openai-mini - label: OpenAI Mini - litellm: -` - for _, test := range []struct { - name string - model string - want string - }{ - { - name: "unstable id", - model: strings.Replace(validPrefix, "openai-mini", "OpenAI Mini", 1) + - " provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].id is invalid", - }, - { - name: "blank label", - model: strings.Replace(validPrefix, "OpenAI Mini", `" "`, 1) + - " provider: openai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].label is invalid", - }, - { - name: "provider", - model: validPrefix + " provider: open ai\n model: gpt-4.1-mini\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].litellm.provider is invalid", - }, - { - name: "model", - model: validPrefix + " provider: openai\n model: \" gpt-4.1-mini\"\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].litellm.model is invalid", - }, - { - name: "endpoint", - model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" + - " endpoint:\n baseUrl: https://operator@api.example/v1\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].litellm.endpoint.baseUrl is invalid", - }, - { - name: "api version", - model: validPrefix + " provider: openai\n model: gpt-4.1-mini\n" + - " endpoint:\n baseUrl: https://api.example/v1\n apiVersion: \"bad version\"\n apiKeyEnv: OPENAI_API_KEY\n", - want: "metadataGeneration.models[0].litellm.endpoint.apiVersion is invalid", - }, - } { - t.Run(test.name, func(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, "metadataGeneration:\n default: openai-mini\n models:\n"+test.model) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("Load() error = %v, want %q", err, test.want) - } - }) - } -} - -func TestLoadRejectsMissingOrUnusableMetadataGenerationSecrets(t *testing.T) { - for _, test := range []struct { - name string - apiKeyEnvYAML string - bundle string - declareBundle bool - want string - }{ - { - name: "missing reference", - apiKeyEnvYAML: "", - bundle: "OPENAI_API_KEY=provider-secret\n", - declareBundle: true, - want: "metadataGeneration.models[0].apiKeyEnv is required unless an explicit keyless endpoint is configured", - }, - { - name: "malformed reference", - apiKeyEnvYAML: " apiKeyEnv: openai-api-key\n", - bundle: "OPENAI_API_KEY=provider-secret\n", - declareBundle: true, - want: "metadataGeneration.models[0].apiKeyEnv is invalid", - }, - { - name: "unallowed reference", - apiKeyEnvYAML: " apiKeyEnv: THT_DWH_API_KEY\n", - bundle: "THT_DWH_API_KEY=dwh-secret\n", - declareBundle: true, - want: "metadataGeneration.models[0].apiKeyEnv is invalid", - }, - { - name: "bundle not declared", - apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n", - bundle: "OPENAI_API_KEY=provider-secret\n", - want: "metadataGeneration keyed models require THT_SECRETS_FILE", - }, - { - name: "reference absent from bundle", - apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n", - bundle: "THT_DWH_API_KEY=dwh-secret\n", - declareBundle: true, - want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is missing", - }, - { - name: "unusable value", - apiKeyEnvYAML: " apiKeyEnv: OPENAI_API_KEY\n", - bundle: "OPENAI_API_KEY=secret with whitespace\n", - declareBundle: true, - want: "metadataGeneration model \"openai-mini\" secret \"OPENAI_API_KEY\" is unusable", - }, - } { - t.Run(test.name, func(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte(test.bundle), 0o600); err != nil { - t.Fatal(err) - } - environment := "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(installationPath) + "\n" - if test.declareBundle { - environment += "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n" - } - appendFile(t, envFile, environment) - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} -`+test.apiKeyEnvYAML) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("Load() error = %v, want %q", err, test.want) - } - if strings.Contains(strings.ToLower(err.Error()), "secret with whitespace") || - strings.Contains(strings.ToLower(err.Error()), "provider-secret") { - t.Fatalf("Load() exposed secret content: %v", err) - } - }) - } -} - -func TestLoadRejectsUnprotectedMetadataGenerationSecretBundle(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX mode assertion; Windows ACL coverage lives in safeio") - } - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o644); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are unavailable") { - t.Fatalf("Load() error = %v, want protected-bundle error", err) - } -} - -func TestLoadRejectsUnknownMetadataGenerationSecretBundleKeys(t *testing.T) { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile( - secretBundle, - []byte("OPENAI_API_KEY=provider-secret\nUNRECOGNIZED_API_KEY=unknown-secret\n"), - 0o600, - ); err != nil { - t.Fatal(err) - } - appendFile(t, envFile, "THT_SECRETS_FILE="+strconv.Quote(secretBundle)+"\n"+ - "THT_INSTALLATION_CONFIG_SOURCE="+strconv.Quote(installationPath)+"\n") - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), "metadataGeneration secrets in THT_SECRETS_FILE are invalid") { - t.Fatalf("Load() error = %v, want invalid-bundle error", err) - } - if strings.Contains(err.Error(), "UNRECOGNIZED_API_KEY") || strings.Contains(err.Error(), "unknown-secret") { - t.Fatalf("Load() exposed rejected bundle content: %v", err) - } -} - -func TestLoadRequiresConfiguredMetadataGenerationToUseTheSameMountedDescriptor(t *testing.T) { - for _, configuredSource := range []string{"", "/different/thothii-installation.yaml"} { - installationPath, _, envFile, _ := writeInstallation(t, "local") - secretBundle := filepath.Join(filepath.Dir(installationPath), "thothii.secrets") - if err := os.WriteFile(secretBundle, []byte("OPENAI_API_KEY=provider-secret\n"), 0o600); err != nil { - t.Fatal(err) - } - environment := "THT_SECRETS_FILE=" + strconv.Quote(secretBundle) + "\n" - if configuredSource != "" { - environment += "THT_INSTALLATION_CONFIG_SOURCE=" + strconv.Quote(configuredSource) + "\n" - } - appendFile(t, envFile, environment) - appendFile(t, installationPath, `metadataGeneration: - default: openai-mini - models: - - id: openai-mini - label: OpenAI Mini - litellm: {provider: openai, model: gpt-4.1-mini} - apiKeyEnv: OPENAI_API_KEY -`) - - _, err := Load(installationPath) - if err == nil || !strings.Contains(err.Error(), "metadataGeneration requires THT_INSTALLATION_CONFIG_SOURCE to match the installation file") { - t.Fatalf("Load() source %q error = %v", configuredSource, err) - } - } -} - -func appendFile(t *testing.T, path, contents string) { - t.Helper() - file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) - if err != nil { - t.Fatal(err) - } - defer file.Close() - if _, err := file.WriteString(contents); err != nil { - t.Fatal(err) - } -} diff --git a/tools/tht/internal/config/installation_model_catalog_test.go b/tools/tht/internal/config/installation_model_catalog_test.go new file mode 100644 index 00000000..ebdfa407 --- /dev/null +++ b/tools/tht/internal/config/installation_model_catalog_test.go @@ -0,0 +1,177 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAcceptsInstallationModelCatalog(t *testing.T) { + path, _, _, _ := writeInstallation(t, "local") + rewriteInstallationCatalog(t, path, validModelCatalogYAML()) + + installation, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if installation.SchemaVersion != 2 { + t.Fatalf("SchemaVersion = %d, want 2", installation.SchemaVersion) + } + if installation.ModelCatalog.Defaults.Session != "zai/glm-5.3" { + t.Fatalf("session default = %q", installation.ModelCatalog.Defaults.Session) + } + models := installation.ModelCatalog.RuntimeModels() + if len(models) != 2 || models[0].ID != "local/qwen" || models[1].ID != "zai/glm-5.3" { + t.Fatalf("RuntimeModels() = %#v", models) + } +} + +func TestLoadRejectsLegacyMetadataGenerationWithMigrationRequired(t *testing.T) { + path, _, _, _ := writeInstallation(t, "local") + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + contents = append(contents, []byte("metadataGeneration:\n default: legacy\n models: []\n")...) + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + _, err = Load(path) + if err == nil || !strings.Contains(err.Error(), "migration_required") { + t.Fatalf("Load() error = %v, want migration_required", err) + } +} + +func TestLoadRejectsLegacyPiCatalogSourcesWithMigrationRequired(t *testing.T) { + path, projectDirectory, _, _ := writeInstallation(t, "local") + legacyDirectory := filepath.Join(projectDirectory, "deploy", "pi") + if err := os.MkdirAll(legacyDirectory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyDirectory, "models.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "migration_required") { + t.Fatalf("Load() error = %v, want migration_required", err) + } +} + +func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) { + tests := []struct { + name string + catalog string + wantError string + secretLine string + }{ + { + name: "unknown session default", + catalog: strings.Replace(validModelCatalogYAML(), "session: zai/glm-5.3", "session: zai/missing", 1), + wantError: "defaults.session", + }, + { + name: "embedding must use the installation Ollama service", + catalog: strings.Replace(validModelCatalogYAML(), "id: ollama/qwen3-embedding:0.6b", "id: external/qwen3-embedding:0.6b", 1), + wantError: "ollama canonical id", + }, + { + name: "metadata default required", + catalog: strings.Replace(validModelCatalogYAML(), " metadataGeneration: local/qwen\n", "", 1), + wantError: "defaults.metadataGeneration", + }, + { + name: "pi auth cannot serve metadata", + catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: pi_auth", 1), + wantError: "pi_auth", + }, + { + name: "none requires endpoint", + catalog: strings.Replace(strings.Replace(validModelCatalogYAML(), "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", "mode: none", 1), " endpoint:\n baseUrl: https://api.z.ai/v1\n", "", 1), + wantError: "explicit endpoint", + }, + { + name: "secret must exist", + catalog: strings.Replace(validModelCatalogYAML(), "mode: none", "mode: secret_env\n apiKeyEnv: ZAI_API_KEY", 1), + wantError: "ZAI_API_KEY", + secretLine: "ZAI_API_KEY=\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path, _, envFile, _ := writeInstallation(t, "local") + rewriteInstallationCatalog(t, path, test.catalog) + if test.secretLine != "" { + secretPath := filepath.Join(filepath.Dir(path), "secrets.env") + if err := os.WriteFile(secretPath, []byte(test.secretLine), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+filepath.Join(filepath.Dir(path), "auth")+"\nTHT_INSTALLATION_CONFIG_SOURCE="+path+"\nTHT_SECRETS_FILE="+secretPath+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("Load() error = %v, want substring %q", err, test.wantError) + } + }) + } +} + +func rewriteInstallationCatalog(t *testing.T, path, catalog string) { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := strings.Split(string(contents), "modelCatalog:\n")[0] + text += catalog + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } +} + +func validModelCatalogYAML() string { + return `modelCatalog: + defaults: + session: zai/glm-5.3 + metadataGeneration: local/qwen + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 + providers: + zai: + endpoint: + baseUrl: https://api.z.ai/v1 + authentication: + mode: none + session: + mode: openai_compatible + models: + glm-5.3: + session: + reasoning: true + contextWindow: 200000 + maxTokens: 131072 + local: + endpoint: + baseUrl: http://ollama:11434/v1 + authentication: + mode: none + session: + mode: openai_compatible + metadataGeneration: + litellmProvider: openai + models: + qwen: + session: + contextWindow: 32768 + maxTokens: 8192 + metadataGeneration: + disableThinking: true +` +} diff --git a/tools/tht/internal/config/installation_test.go b/tools/tht/internal/config/installation_test.go index 13223393..1042c2e7 100644 --- a/tools/tht/internal/config/installation_test.go +++ b/tools/tht/internal/config/installation_test.go @@ -31,6 +31,7 @@ func TestLoadSelectsLocalComposeFilesForAnInstallationInPathsWithSpaces(t *testi filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml"), override, + installation.ModelProjectionComposePath(), } assertStringsEqual(t, installation.ComposeFiles(), want) } @@ -48,6 +49,7 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) { filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.server.yaml"), override, + installation.ModelProjectionComposePath(), } assertStringsEqual(t, installation.ComposeFiles(), want) } @@ -91,6 +93,7 @@ func TestLoadAcceptsServerRuntimeProjectionAndPlacesAutomaticOverrideBeforeCurre filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.server.yaml"), override, + installation.ModelProjectionComposePath(), automaticOverride, currentImage, } @@ -175,11 +178,11 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil { t.Fatal(err) } - contents := "profile: local\nprojectDirectory: " + projectDirectory + + contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") + "\nworkspaceRepository:\n remote: " + remote + - "\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" + "\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" + minimalModelCatalogYAML() if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } @@ -201,9 +204,10 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) { if err := os.WriteFile(gitOverride, []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } - contents := "profile: local\nprojectDirectory: " + projectDirectory + + contents := "schemaVersion: 2\nprofile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n" contents = strings.Replace(contents, "\noverrides:", "\nauthentication:\n configDirectory: "+filepath.Join(filepath.Dir(envFile), "auth")+"\noverrides:", 1) + contents += minimalModelCatalogYAML() if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } @@ -388,10 +392,11 @@ func writeRuntimeProjectionFixture(t *testing.T, installationPath, envFile, prof t.Fatal(err) } projectDirectory := filepath.Join(filepath.Dir(installationPath), "project directory with spaces") - contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n" + contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n" for _, override := range overrides { contents += " - " + override + "\n" } + contents += minimalModelCatalogYAML() if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } @@ -429,13 +434,32 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st t.Fatal(err) } installationPath := filepath.Join(root, "thothii-installation.yaml") - contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n" + contents := "schemaVersion: 2\nprofile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n" + minimalModelCatalogYAML() if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } return installationPath, projectDirectory, envFile, override } +func minimalModelCatalogYAML() string { + return `modelCatalog: + defaults: + session: deepseek/deepseek-v4-pro + embedding: + id: ollama/qwen3-embedding:0.6b + dimensions: 1024 + providers: + deepseek: + authentication: + mode: pi_auth + session: + mode: pi_builtin + models: + deepseek-v4-pro: + session: {} +` +} + func assertStringsEqual(t *testing.T, got, want []string) { t.Helper() if len(got) != len(want) { diff --git a/tools/tht/internal/config/model_catalog.go b/tools/tht/internal/config/model_catalog.go new file mode 100644 index 00000000..e8e9ecbb --- /dev/null +++ b/tools/tht/internal/config/model_catalog.go @@ -0,0 +1,315 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "unicode" +) + +const maxCatalogModels = 64 + +var catalogKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) +var catalogModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`) +var catalogAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +// ModelCatalog is the only operator-authored source for model identity and runtime eligibility. +type ModelCatalog struct { + Defaults ModelCatalogDefaults `yaml:"defaults" json:"defaults"` + Embedding ModelCatalogEmbedding `yaml:"embedding" json:"embedding"` + Providers map[string]ModelProvider `yaml:"providers" json:"providers"` +} + +type ModelCatalogDefaults struct { + Session string `yaml:"session" json:"session"` + MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"` +} + +type ModelCatalogEmbedding struct { + ID string `yaml:"id" json:"id"` + Dimensions int `yaml:"dimensions" json:"dimensions"` +} + +type ModelEndpoint struct { + BaseURL string `yaml:"baseUrl" json:"baseUrl"` + APIVersion string `yaml:"apiVersion,omitempty" json:"apiVersion,omitempty"` +} + +type ModelAuthentication struct { + Mode string `yaml:"mode" json:"mode"` + APIKeyEnv string `yaml:"apiKeyEnv,omitempty" json:"apiKeyEnv,omitempty"` +} + +type ModelSessionAdapter struct { + Mode string `yaml:"mode" json:"mode"` +} + +type ModelMetadataAdapter struct { + LiteLLMProvider string `yaml:"litellmProvider" json:"litellmProvider"` +} + +type ModelProvider struct { + Endpoint *ModelEndpoint `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` + Authentication ModelAuthentication `yaml:"authentication" json:"authentication"` + Session *ModelSessionAdapter `yaml:"session,omitempty" json:"session,omitempty"` + MetadataGeneration *ModelMetadataAdapter `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"` + Models map[string]CatalogModel `yaml:"models" json:"models"` +} + +type ModelCost struct { + Input float64 `yaml:"input" json:"input"` + Output float64 `yaml:"output" json:"output"` + CacheRead float64 `yaml:"cacheRead" json:"cacheRead"` + CacheWrite float64 `yaml:"cacheWrite" json:"cacheWrite"` +} + +type ModelCompatibility struct { + SupportsDeveloperRole bool `yaml:"supportsDeveloperRole" json:"supportsDeveloperRole"` + SupportsReasoningEffort bool `yaml:"supportsReasoningEffort" json:"supportsReasoningEffort"` + SupportsStore bool `yaml:"supportsStore" json:"supportsStore"` + MaxTokensField string `yaml:"maxTokensField" json:"maxTokensField,omitempty"` +} + +type SessionModel struct { + Reasoning bool `yaml:"reasoning,omitempty" json:"reasoning"` + Input []string `yaml:"input,omitempty" json:"input,omitempty"` + Cost *ModelCost `yaml:"cost,omitempty" json:"cost,omitempty"` + ContextWindow int `yaml:"contextWindow,omitempty" json:"contextWindow,omitempty"` + MaxTokens int `yaml:"maxTokens,omitempty" json:"maxTokens,omitempty"` + Compatibility *ModelCompatibility `yaml:"compatibility,omitempty" json:"compatibility,omitempty"` +} + +type MetadataGenerationModel struct { + DisableThinking bool `yaml:"disableThinking,omitempty" json:"disableThinking"` +} + +type CatalogModel struct { + Label string `yaml:"label,omitempty" json:"label,omitempty"` + UpstreamModel string `yaml:"upstreamModel,omitempty" json:"upstreamModel,omitempty"` + Session *SessionModel `yaml:"session,omitempty" json:"session,omitempty"` + MetadataGeneration *MetadataGenerationModel `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"` +} + +// RuntimeModel is the flattened, canonical representation shared by runtime projections. +type RuntimeModel struct { + ID string + Provider string + Model string + Label string + UpstreamModel string + Endpoint *ModelEndpoint + Authentication ModelAuthentication + SessionAdapter *ModelSessionAdapter + MetadataAdapter *ModelMetadataAdapter + Session *SessionModel + MetadataGeneration *MetadataGenerationModel +} + +// RuntimeModels returns all catalog models in canonical identity order. +func (c ModelCatalog) RuntimeModels() []RuntimeModel { + models := make([]RuntimeModel, 0) + for providerID, provider := range c.Providers { + for modelID, model := range provider.Models { + id := providerID + "/" + modelID + label := model.Label + if label == "" { + label = id + } + upstream := model.UpstreamModel + if upstream == "" { + upstream = modelID + } + models = append(models, RuntimeModel{ + ID: id, Provider: providerID, Model: modelID, Label: label, + UpstreamModel: upstream, Endpoint: provider.Endpoint, + Authentication: provider.Authentication, SessionAdapter: provider.Session, + MetadataAdapter: provider.MetadataGeneration, Session: model.Session, + MetadataGeneration: model.MetadataGeneration, + }) + } + } + sort.Slice(models, func(left, right int) bool { return models[left].ID < models[right].ID }) + return models +} + +// Validate rejects ambiguous, duplicated, or runtime-incompatible catalog declarations. +func (c ModelCatalog) Validate(environment map[string]string) error { + if c.Defaults.Session == "" { + return errors.New("modelCatalog.defaults.session is required") + } + if !catalogModelIDPattern.MatchString(c.Embedding.ID) || !strings.HasPrefix(c.Embedding.ID, "ollama/") || c.Embedding.Dimensions <= 0 { + return errors.New("modelCatalog.embedding requires an ollama canonical id and positive dimensions") + } + if len(c.Providers) == 0 { + return errors.New("modelCatalog.providers must not be empty") + } + if countCatalogModels(c) > maxCatalogModels { + return fmt.Errorf("modelCatalog must contain at most %d models", maxCatalogModels) + } + + hasMetadata := false + secretsNeeded := make(map[string][]string) + for providerID, provider := range c.Providers { + if !catalogKeyPattern.MatchString(providerID) { + return fmt.Errorf("modelCatalog provider %q is invalid", providerID) + } + if len(provider.Models) == 0 { + return fmt.Errorf("modelCatalog provider %q has no models", providerID) + } + if err := validateCatalogEndpoint(providerID, provider.Endpoint); err != nil { + return err + } + hasSession, providerHasMetadata := false, false + for modelID, model := range provider.Models { + if !catalogKeyPattern.MatchString(modelID) { + return fmt.Errorf("modelCatalog model %q/%q is invalid", providerID, modelID) + } + canonical := providerID + "/" + modelID + if model.Session == nil && model.MetadataGeneration == nil { + return fmt.Errorf("modelCatalog model %q has no runtime use", canonical) + } + if model.Label != "" && (len(model.Label) > 128 || strings.TrimSpace(model.Label) != model.Label || strings.IndexFunc(model.Label, unicode.IsControl) >= 0) { + return fmt.Errorf("modelCatalog model %q label is invalid", canonical) + } + if model.Session != nil { + hasSession = true + } + if model.MetadataGeneration != nil { + hasMetadata, providerHasMetadata = true, true + if model.MetadataGeneration.DisableThinking && provider.Endpoint == nil { + return fmt.Errorf("modelCatalog model %q disableThinking requires an explicit endpoint", canonical) + } + } + } + if err := validateCatalogProvider(providerID, provider, hasSession, providerHasMetadata); err != nil { + return err + } + if provider.Authentication.Mode == "secret_env" { + secretsNeeded[provider.Authentication.APIKeyEnv] = append(secretsNeeded[provider.Authentication.APIKeyEnv], providerID) + } + } + + models := c.RuntimeModels() + if !runtimeModelEligible(models, c.Defaults.Session, "session") { + return fmt.Errorf("modelCatalog.defaults.session %q is not a session model", c.Defaults.Session) + } + if hasMetadata && c.Defaults.MetadataGeneration == "" { + return errors.New("modelCatalog.defaults.metadataGeneration is required when metadata models are configured") + } + if !hasMetadata && c.Defaults.MetadataGeneration != "" { + return errors.New("modelCatalog.defaults.metadataGeneration must be empty when metadata generation is unavailable") + } + if hasMetadata && !runtimeModelEligible(models, c.Defaults.MetadataGeneration, "metadata") { + return fmt.Errorf("modelCatalog.defaults.metadataGeneration %q is not a metadata-generation model", c.Defaults.MetadataGeneration) + } + return validateCatalogSecrets(environment, secretsNeeded) +} + +func validateCatalogProvider(id string, provider ModelProvider, hasSession, hasMetadata bool) error { + auth := provider.Authentication + switch auth.Mode { + case "secret_env": + if !metadataSecretBundleKeyPattern.MatchString(auth.APIKeyEnv) { + return fmt.Errorf("modelCatalog provider %q authentication.apiKeyEnv is invalid", id) + } + if _, allowed := metadataAPIKeyEnvironments[auth.APIKeyEnv]; !allowed { + return fmt.Errorf("modelCatalog provider %q authentication.apiKeyEnv is invalid", id) + } + case "pi_auth": + if auth.APIKeyEnv != "" || hasMetadata || !hasSession || provider.Session == nil || provider.Session.Mode != "pi_builtin" { + return fmt.Errorf("modelCatalog provider %q pi_auth is valid only for session-only pi_builtin providers", id) + } + case "none": + if auth.APIKeyEnv != "" || provider.Endpoint == nil { + return fmt.Errorf("modelCatalog provider %q authentication none requires an explicit endpoint", id) + } + default: + return fmt.Errorf("modelCatalog provider %q authentication.mode is invalid", id) + } + if hasSession { + if provider.Session == nil || (provider.Session.Mode != "pi_builtin" && provider.Session.Mode != "openai_compatible") { + return fmt.Errorf("modelCatalog provider %q requires a supported session adapter", id) + } + if provider.Session.Mode == "openai_compatible" && provider.Endpoint == nil { + return fmt.Errorf("modelCatalog provider %q openai_compatible requires an explicit endpoint", id) + } + if provider.Session.Mode == "pi_builtin" { + for modelID, model := range provider.Models { + if model.Session != nil && (model.Session.ContextWindow != 0 || model.Session.MaxTokens != 0 || len(model.Session.Input) != 0 || model.Session.Cost != nil || model.Session.Compatibility != nil || model.Session.Reasoning) { + return fmt.Errorf("modelCatalog model %q/%q must use an empty session block for pi_builtin", id, modelID) + } + } + } else { + for modelID, model := range provider.Models { + if model.Session != nil && (model.Session.ContextWindow <= 0 || model.Session.MaxTokens <= 0) { + return fmt.Errorf("modelCatalog model %q/%q requires contextWindow and maxTokens", id, modelID) + } + } + } + } else if provider.Session != nil { + return fmt.Errorf("modelCatalog provider %q has a session adapter but no session models", id) + } + if hasMetadata { + if provider.MetadataGeneration == nil || provider.MetadataGeneration.LiteLLMProvider == "" { + return fmt.Errorf("modelCatalog provider %q requires metadataGeneration.litellmProvider", id) + } + } else if provider.MetadataGeneration != nil { + return fmt.Errorf("modelCatalog provider %q has a metadata adapter but no metadata models", id) + } + return nil +} + +func validateCatalogEndpoint(provider string, endpoint *ModelEndpoint) error { + if endpoint == nil { + return nil + } + parsed, err := url.Parse(endpoint.BaseURL) + if err != nil || strings.TrimSpace(endpoint.BaseURL) != endpoint.BaseURL || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("modelCatalog provider %q endpoint.baseUrl is invalid", provider) + } + if endpoint.APIVersion != "" && !catalogAPIVersionPattern.MatchString(endpoint.APIVersion) { + return fmt.Errorf("modelCatalog provider %q endpoint.apiVersion is invalid", provider) + } + return nil +} + +func validateCatalogSecrets(environment map[string]string, needed map[string][]string) error { + if len(needed) == 0 { + return nil + } + bundle := environment["THT_SECRETS_FILE"] + if bundle == "" { + return errors.New("modelCatalog secret_env providers require THT_SECRETS_FILE") + } + secrets, err := readMetadataGenerationSecrets(bundle) + if err != nil { + return errors.New("modelCatalog secrets in THT_SECRETS_FILE are unavailable or invalid") + } + for key := range needed { + value, exists := secrets[key] + if !exists || !usableMetadataGenerationSecret(value) { + return fmt.Errorf("modelCatalog secret %q is missing or unusable", key) + } + } + return nil +} + +func runtimeModelEligible(models []RuntimeModel, id, usage string) bool { + for _, model := range models { + if model.ID == id && ((usage == "session" && model.Session != nil) || (usage == "metadata" && model.MetadataGeneration != nil)) { + return true + } + } + return false +} + +func countCatalogModels(c ModelCatalog) int { + count := 0 + for _, provider := range c.Providers { + count += len(provider.Models) + } + return count +} diff --git a/tools/tht/internal/doctor/report.go b/tools/tht/internal/doctor/report.go index 1cd9e4f8..54cd5823 100644 --- a/tools/tht/internal/doctor/report.go +++ b/tools/tht/internal/doctor/report.go @@ -15,6 +15,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/service" @@ -101,7 +102,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner return Report{}, errors.New("doctor requires an HTTP probe") } secretValues, secretErr := secretValues(installation) - report := Report{Checks: make([]Check, 0, 12)} + report := Report{Checks: make([]Check, 0, 13)} add := func(name, status, detail string) { report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)}) } @@ -111,6 +112,13 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner } else { add("descriptor", StatusPassed, "installation descriptor is loaded") } + if drift, err := modelprojection.Check(installation); err != nil { + add("model-projection", StatusFailed, "model runtime projections could not be recomputed") + } else if len(drift) > 0 { + add("model-projection", StatusFailed, "generated model runtime projections differ: "+strings.Join(drift, ", ")) + } else { + add("model-projection", StatusPassed, "generated model runtime projections match modelCatalog") + } if err := filePermissions(installation); err != nil || secretErr != nil { if secretErr != nil { add("files", StatusFailed, "declared secret files could not be read") diff --git a/tools/tht/internal/doctor/report_test.go b/tools/tht/internal/doctor/report_test.go index ac0bad33..e6618026 100644 --- a/tools/tht/internal/doctor/report_test.go +++ b/tools/tht/internal/doctor/report_test.go @@ -11,6 +11,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" ) // Catches treating an unavailable Docker executable as a successful diagnosis. @@ -132,7 +133,7 @@ func TestRunFailsAuthenticationWithoutExecWhenCoreIsRunningButUnhealthy(t *testi if report.OK || checkStatus(report, "authentication") != StatusFailed { t.Fatalf("Run() report = %#v, want deterministic failed authentication", report) } - assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) + assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T ") { t.Fatalf("Run() invoked exec -T while core was unhealthy: %v", runner.calls) } @@ -170,7 +171,7 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin if !report.OK || checkStatus(report, "authentication") != "passed" || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" { t.Fatalf("Run() report = %#v, want successful container diagnostics", report) } - assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) + assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) calls := strings.Join(runner.calls, "\n") if !strings.Contains(calls, "exec -T core node dist/auth/diagnostic-command.js --json") { t.Fatalf("Run() calls = %s, want core-local authentication diagnostic", calls) @@ -261,7 +262,7 @@ func TestRunFailsBeforeDockerWhenDeclaredSecretCorpusIsIncomplete(t *testing.T) if strings.Contains(reportText(report), missing) { t.Fatalf("incomplete corpus report exposed a secret path: %#v", report) } - assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) + assertChecklist(t, report, []string{"descriptor", "model-projection", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) } func doctorInstallation(t *testing.T, _ string) config.Installation { @@ -290,7 +291,11 @@ func doctorInstallation(t *testing.T, _ string) config.Installation { if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil { t.Fatal(err) } - return config.Installation{Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "local", ProjectDirectory: project, EnvFile: envFile} + installation := config.Installation{Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "local", ProjectDirectory: project, EnvFile: envFile} + if err := modelprojection.Generate(installation); err != nil { + t.Fatal(err) + } + return installation } type doctorRunner struct { diff --git a/tools/tht/internal/modelmigration/migration.go b/tools/tht/internal/modelmigration/migration.go new file mode 100644 index 00000000..93b8bf09 --- /dev/null +++ b/tools/tht/internal/modelmigration/migration.go @@ -0,0 +1,468 @@ +// Package modelmigration creates a reviewable v2 installation candidate from legacy model files. +package modelmigration + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/compose-spec/compose-go/v2/dotenv" + "gopkg.in/yaml.v3" +) + +const maxMigrationInputBytes = 1 << 20 + +// Request contains the facts that do not exist unambiguously in the three legacy model sources. +type Request struct { + InstallationPath string + OutputPath string + SessionDefault string + EmbeddingID string + EmbeddingDimensions int +} + +type legacyDescriptor struct { + SchemaVersion int `yaml:"schemaVersion,omitempty"` + Profile string `yaml:"profile"` + ProjectDirectory string `yaml:"projectDirectory"` + EnvFile string `yaml:"envFile"` + WorkspaceRepository workspaceRepository `yaml:"workspaceRepository"` + Authentication authenticationDescriptor `yaml:"authentication"` + MetadataGeneration legacyMetadataCatalog `yaml:"metadataGeneration"` + Overrides []string `yaml:"overrides,omitempty"` +} + +type workspaceRepository struct { + Remote string `yaml:"remote"` + Branch string `yaml:"branch"` + Access string `yaml:"access"` +} + +type authenticationDescriptor struct { + ConfigDirectory string `yaml:"configDirectory"` + RuntimeProjection *runtimeProjection `yaml:"runtimeProjection,omitempty"` +} + +type runtimeProjection struct { + Directory string `yaml:"directory"` + UID uint32 `yaml:"uid"` + GID uint32 `yaml:"gid"` +} + +type legacyMetadataCatalog struct { + Default string `yaml:"default"` + Models []legacyMetadataModel `yaml:"models"` +} + +type legacyMetadataModel struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + LiteLLM legacyLiteLLM `yaml:"litellm"` + APIKeyEnv string `yaml:"apiKeyEnv"` +} + +type legacyLiteLLM struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` + DisableThinking bool `yaml:"disableThinking"` + Endpoint *config.ModelEndpoint `yaml:"endpoint"` +} + +type piCatalog struct { + Providers map[string]piProvider `json:"providers"` +} + +type piProvider struct { + BaseURL string `json:"baseUrl"` + API string `json:"api"` + APIKey string `json:"apiKey"` + Models []piModel `json:"models"` +} + +type piModel struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input,omitempty"` + Cost *config.ModelCost `json:"cost,omitempty"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + Compat *config.ModelCompatibility `json:"compat,omitempty"` +} + +type piSettings struct { + DefaultProjectTrust string `json:"defaultProjectTrust"` + EnabledModels []string `json:"enabledModels"` +} + +type candidateDescriptor struct { + SchemaVersion int `yaml:"schemaVersion"` + Profile string `yaml:"profile"` + ProjectDirectory string `yaml:"projectDirectory"` + EnvFile string `yaml:"envFile"` + WorkspaceRepository workspaceRepository `yaml:"workspaceRepository"` + ModelCatalog config.ModelCatalog `yaml:"modelCatalog"` + Authentication authenticationDescriptor `yaml:"authentication"` + Overrides []string `yaml:"overrides,omitempty"` +} + +// Run reads every legacy source, reports ambiguity without publishing, and otherwise writes one +// new candidate. It never modifies or removes the legacy files. +func Run(request Request) error { + if !filepath.IsAbs(request.InstallationPath) || filepath.Base(request.InstallationPath) != "thothii-installation.yaml" { + return errors.New("installation migration requires an absolute thothii-installation.yaml path") + } + if !filepath.IsAbs(request.OutputPath) || filepath.Base(request.OutputPath) != "thothii-installation.yaml" || + filepath.Clean(request.OutputPath) == filepath.Clean(request.InstallationPath) { + return errors.New("installation migration output must be a different absolute thothii-installation.yaml path") + } + if request.SessionDefault == "" || request.EmbeddingID == "" || request.EmbeddingDimensions <= 0 { + return errors.New("installation migration requires session default, embedding id, and positive embedding dimensions") + } + if _, err := os.Lstat(request.OutputPath); err == nil { + return errors.New("installation migration output already exists") + } else if !os.IsNotExist(err) { + return errors.New("installation migration output is unavailable") + } + + var legacy legacyDescriptor + if err := decodeYAML(request.InstallationPath, &legacy); err != nil { + return fmt.Errorf("legacy installation: %w", err) + } + if legacy.SchemaVersion != 0 { + return errors.New("legacy installation: schemaVersion must be absent") + } + var piModels piCatalog + if err := decodeJSON(filepath.Join(legacy.ProjectDirectory, "deploy", "pi", "models.json"), &piModels); err != nil { + return fmt.Errorf("deploy/pi/models.json: %w", err) + } + var settings piSettings + if err := decodeJSON(filepath.Join(legacy.ProjectDirectory, "deploy", "pi", "settings.json"), &settings); err != nil { + return fmt.Errorf("deploy/pi/settings.json: %w", err) + } + + catalog, err := reconcileCatalog(legacy, piModels, settings, request) + if err != nil { + return err + } + environment, err := parseEnvironment(legacy.EnvFile) + if err != nil { + return errors.New("legacy installation envFile is unavailable or invalid") + } + if err := catalog.Validate(environment); err != nil { + return fmt.Errorf("modelCatalog candidate: %w", err) + } + candidate := candidateDescriptor{ + SchemaVersion: 2, Profile: legacy.Profile, ProjectDirectory: legacy.ProjectDirectory, + EnvFile: legacy.EnvFile, WorkspaceRepository: legacy.WorkspaceRepository, + ModelCatalog: catalog, Authentication: legacy.Authentication, Overrides: legacy.Overrides, + } + contents, err := yaml.Marshal(candidate) + if err != nil { + return errors.New("installation migration candidate could not be encoded") + } + return publishCandidate(request.OutputPath, contents) +} + +func reconcileCatalog(legacy legacyDescriptor, piModels piCatalog, settings piSettings, request Request) (config.ModelCatalog, error) { + catalog := config.ModelCatalog{ + Defaults: config.ModelCatalogDefaults{Session: request.SessionDefault}, + Embedding: config.ModelCatalogEmbedding{ID: request.EmbeddingID, Dimensions: request.EmbeddingDimensions}, + Providers: make(map[string]config.ModelProvider), + } + enabled := make(map[string]struct{}, len(settings.EnabledModels)) + for index, canonical := range settings.EnabledModels { + providerID, modelID, ok := splitCanonical(canonical) + if !ok { + return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: canonical provider/model identity is required", index) + } + if _, duplicate := enabled[canonical]; duplicate { + return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: duplicate identity %q", index, canonical) + } + enabled[canonical] = struct{}{} + piProvider, custom := piModels.Providers[providerID] + if !custom { + provider := catalog.Providers[providerID] + if len(provider.Models) == 0 { + provider = config.ModelProvider{ + Authentication: config.ModelAuthentication{Mode: "pi_auth"}, + Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, + Models: make(map[string]config.CatalogModel), + } + } + provider.Models[modelID] = config.CatalogModel{Session: &config.SessionModel{}} + catalog.Providers[providerID] = provider + continue + } + model, found := findPiModel(piProvider.Models, modelID) + if !found { + return catalog, fmt.Errorf("deploy/pi/settings.json enabledModels[%d]: %q is missing from deploy/pi/models.json", index, canonical) + } + provider, exists := catalog.Providers[providerID] + if !exists { + auth, authErr := migratePiAuthentication(providerID, piProvider) + if authErr != nil { + return catalog, authErr + } + provider = config.ModelProvider{ + Endpoint: &config.ModelEndpoint{BaseURL: piProvider.BaseURL}, Authentication: auth, + Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, + Models: make(map[string]config.CatalogModel), + } + } + provider.Models[modelID] = config.CatalogModel{ + Label: model.Name, + Session: &config.SessionModel{ + Reasoning: model.Reasoning, Input: model.Input, Cost: model.Cost, + ContextWindow: model.ContextWindow, MaxTokens: model.MaxTokens, + Compatibility: model.Compat, + }, + } + catalog.Providers[providerID] = provider + } + if _, ok := enabled[request.SessionDefault]; !ok { + return catalog, fmt.Errorf("session default %q is not enabled by deploy/pi/settings.json", request.SessionDefault) + } + + metadataIDs := make(map[string]string, len(legacy.MetadataGeneration.Models)) + for index, old := range legacy.MetadataGeneration.Models { + canonical, mergeErr := mergeMetadataModel(&catalog, old) + if mergeErr != nil { + return catalog, fmt.Errorf("metadataGeneration.models[%d]: %w", index, mergeErr) + } + if old.ID == "" { + return catalog, fmt.Errorf("metadataGeneration.models[%d]: id is required", index) + } + if _, duplicate := metadataIDs[old.ID]; duplicate { + return catalog, fmt.Errorf("metadataGeneration.models[%d]: duplicate legacy id %q", index, old.ID) + } + metadataIDs[old.ID] = canonical + } + if len(metadataIDs) > 0 { + mapped, ok := metadataIDs[legacy.MetadataGeneration.Default] + if !ok { + return catalog, errors.New("metadataGeneration.default does not identify one legacy model") + } + catalog.Defaults.MetadataGeneration = mapped + } else if legacy.MetadataGeneration.Default != "" { + return catalog, errors.New("metadataGeneration.default is set without models") + } + return catalog, nil +} + +func mergeMetadataModel(catalog *config.ModelCatalog, old legacyMetadataModel) (string, error) { + if old.LiteLLM.Provider == "" || old.LiteLLM.Model == "" { + return "", errors.New("litellm.provider and litellm.model are required") + } + type match struct{ provider, model string } + matches := make([]match, 0, 1) + for providerID, provider := range catalog.Providers { + if old.LiteLLM.Endpoint != nil && !sameEndpoint(provider.Endpoint, old.LiteLLM.Endpoint) { + continue + } + for modelID, model := range provider.Models { + upstream := model.UpstreamModel + if upstream == "" { + upstream = modelID + } + if upstream == old.LiteLLM.Model && (old.LiteLLM.Endpoint != nil || providerID == old.LiteLLM.Provider) { + matches = append(matches, match{providerID, modelID}) + } + } + } + if len(matches) > 1 { + return "", errors.New("model identity matches more than one Pi provider") + } + providerID, modelID := old.LiteLLM.Provider, old.LiteLLM.Model + if len(matches) == 1 { + providerID, modelID = matches[0].provider, matches[0].model + } + provider, exists := catalog.Providers[providerID] + desiredAuth := config.ModelAuthentication{Mode: "none"} + if old.APIKeyEnv != "" { + desiredAuth = config.ModelAuthentication{Mode: "secret_env", APIKeyEnv: old.APIKeyEnv} + } + if !exists { + provider = config.ModelProvider{ + Endpoint: old.LiteLLM.Endpoint, Authentication: desiredAuth, + MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: old.LiteLLM.Provider}, + Models: make(map[string]config.CatalogModel), + } + } else { + if provider.Authentication != desiredAuth { + return "", fmt.Errorf("provider %q authentication conflicts between Pi and metadata generation", providerID) + } + if old.LiteLLM.Endpoint != nil && !sameEndpoint(provider.Endpoint, old.LiteLLM.Endpoint) { + return "", fmt.Errorf("provider %q endpoint conflicts between Pi and metadata generation", providerID) + } + if provider.MetadataGeneration != nil && provider.MetadataGeneration.LiteLLMProvider != old.LiteLLM.Provider { + return "", fmt.Errorf("provider %q LiteLLM adapter is ambiguous", providerID) + } + provider.MetadataGeneration = &config.ModelMetadataAdapter{LiteLLMProvider: old.LiteLLM.Provider} + } + model := provider.Models[modelID] + if model.MetadataGeneration != nil { + return "", fmt.Errorf("canonical identity %q is claimed by more than one legacy metadata model", providerID+"/"+modelID) + } + if model.Label == "" { + model.Label = old.Label + } + if modelID != old.LiteLLM.Model { + model.UpstreamModel = old.LiteLLM.Model + } + model.MetadataGeneration = &config.MetadataGenerationModel{DisableThinking: old.LiteLLM.DisableThinking} + provider.Models[modelID] = model + catalog.Providers[providerID] = provider + return providerID + "/" + modelID, nil +} + +func migratePiAuthentication(providerID string, provider piProvider) (config.ModelAuthentication, error) { + if provider.BaseURL == "" || provider.API != "openai-completions" { + return config.ModelAuthentication{}, fmt.Errorf("deploy/pi/models.json provider %q: only explicit openai-completions endpoints can migrate", providerID) + } + if strings.HasPrefix(provider.APIKey, "$") && len(provider.APIKey) > 1 { + return config.ModelAuthentication{Mode: "secret_env", APIKeyEnv: provider.APIKey[1:]}, nil + } + if provider.APIKey == "local" { + return config.ModelAuthentication{Mode: "none"}, nil + } + return config.ModelAuthentication{}, fmt.Errorf("deploy/pi/models.json provider %q: API key cannot be reconciled without guessing", providerID) +} + +func findPiModel(models []piModel, id string) (piModel, bool) { + var found piModel + count := 0 + for _, model := range models { + if model.ID == id { + found, count = model, count+1 + } + } + return found, count == 1 +} + +func splitCanonical(value string) (string, string, bool) { + parts := strings.Split(value, "/") + return first(parts), second(parts), len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +func first(parts []string) string { + if len(parts) > 0 { + return parts[0] + } + return "" +} +func second(parts []string) string { + if len(parts) > 1 { + return parts[1] + } + return "" +} + +func sameEndpoint(left, right *config.ModelEndpoint) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.BaseURL == right.BaseURL && left.APIVersion == right.APIVersion +} + +func decodeYAML(path string, target any) error { + source, err := readBounded(path) + if err != nil { + return err + } + decoder := yaml.NewDecoder(bytes.NewReader(source)) + decoder.KnownFields(true) + if err := decoder.Decode(target); err != nil { + return errors.New("invalid or unsupported YAML") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("multiple YAML documents are not supported") + } + return nil +} + +func decodeJSON(path string, target any) error { + source, err := readBounded(path) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return errors.New("invalid or unsupported JSON") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("trailing JSON is not supported") + } + return nil +} + +func readBounded(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 1 || info.Size() > maxMigrationInputBytes { + return nil, errors.New("source is unavailable or unsafe") + } + return os.ReadFile(path) +} + +func parseEnvironment(path string) (map[string]string, error) { + source, err := readBounded(path) + if err != nil { + return nil, err + } + return dotenv.ParseWithLookup(bytes.NewReader(source), os.LookupEnv) +} + +func publishCandidate(path string, contents []byte) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return errors.New("create migration output directory") + } + temporary, err := os.CreateTemp(directory, ".installation-v2-*") + if err != nil { + return errors.New("create installation migration candidate") + } + name := temporary.Name() + published := false + defer func() { + if !published { + _ = os.Remove(name) + } + }() + if err := temporary.Chmod(0o600); err == nil { + _, err = temporary.Write(contents) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return errors.New("write installation migration candidate") + } + if err := os.Link(name, path); err != nil { + if os.IsExist(err) { + return errors.New("installation migration output already exists") + } + return errors.New("publish installation migration candidate") + } + published = true + _ = os.Remove(name) + return nil +} + +// ParseDimensions is shared by the host command without accepting floats or signs. +func ParseDimensions(value string) (int, error) { + dimensions, err := strconv.Atoi(value) + if err != nil || dimensions <= 0 { + return 0, errors.New("embedding dimensions must be a positive integer") + } + return dimensions, nil +} diff --git a/tools/tht/internal/modelmigration/migration_test.go b/tools/tht/internal/modelmigration/migration_test.go new file mode 100644 index 00000000..7849f613 --- /dev/null +++ b/tools/tht/internal/modelmigration/migration_test.go @@ -0,0 +1,158 @@ +package modelmigration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" + "gopkg.in/yaml.v3" +) + +func TestRunEmitsValidatedCandidateWithoutChangingLegacyInputs(t *testing.T) { + fixture := migrationFixture(t, false) + original, _ := os.ReadFile(fixture.legacy) + request := fixture.request() + if err := Run(request); err != nil { + t.Fatalf("Run() error = %v", err) + } + after, _ := os.ReadFile(fixture.legacy) + if string(after) != string(original) { + t.Fatal("legacy installation descriptor changed") + } + // The strict v2 loader deliberately refuses legacy Pi sources; removing them is the + // operator's explicit cut-over after reviewing the candidate. + var raw legacyDescriptor + decodeYAMLFile(t, fixture.legacy, &raw) + if err := os.Remove(filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json")); err != nil { + t.Fatal(err) + } + candidate, err := config.Load(request.OutputPath) + if err != nil { + t.Fatalf("candidate config.Load() error = %v", err) + } + if candidate.SchemaVersion != 2 || candidate.ModelCatalog.Defaults.Session != "zai/glm-5.3" { + t.Fatalf("candidate = %#v", candidate.ModelCatalog) + } + if got := candidate.ModelCatalog.Defaults.MetadataGeneration; got != "zai/glm-5.3" { + t.Fatalf("metadata default = %q", got) + } + if candidate.ModelCatalog.Providers["deepseek"].Session.Mode != "pi_builtin" { + t.Fatal("built-in Pi model was not migrated") + } +} + +func decodeYAMLFile(t *testing.T, path string, target any) { + t.Helper() + source, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(source, target); err != nil { + t.Fatal(err) + } +} + +func TestRunReportsProviderAuthenticationConflictAndPublishesNothing(t *testing.T) { + fixture := migrationFixture(t, true) + err := Run(fixture.request()) + if err == nil || !strings.Contains(err.Error(), "metadataGeneration.models[0]") || + !strings.Contains(err.Error(), "authentication") { + t.Fatalf("Run() error = %v", err) + } + if _, statErr := os.Stat(fixture.request().OutputPath); !os.IsNotExist(statErr) { + t.Fatalf("candidate was published after conflict: %v", statErr) + } +} + +type migrationTestFixture struct { + legacy string + output string +} + +func (f migrationTestFixture) request() Request { + return Request{ + InstallationPath: f.legacy, + OutputPath: f.output, + SessionDefault: "zai/glm-5.3", + EmbeddingID: "ollama/qwen3-embedding:0.6b", + EmbeddingDimensions: 1024, + } +} + +func migrationFixture(t *testing.T, conflict bool) migrationTestFixture { + t.Helper() + base, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(base, "tht-model-migration-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + project := filepath.Join(root, "project") + if err := os.MkdirAll(filepath.Join(project, "deploy", "pi"), 0o755); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(project, "compose.yaml"), filepath.Join(project, "deploy", "compose.local.yaml")} { + if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + env := filepath.Join(root, "operator.env") + secrets := filepath.Join(root, "secrets.env") + auth := filepath.Join(root, "auth") + if err := os.WriteFile(secrets, []byte("ZAI_API_KEY=test\nDEEPSEEK_API_KEY=test\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env, []byte("THT_SECRETS_FILE="+secrets+"\nTHT_AUTH_CONFIG_ROOT="+auth+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(auth, 0o700); err != nil { + t.Fatal(err) + } + metadata := ` default: glm-53 + models: + - id: glm-53 + label: GLM 5.3 + litellm: + provider: openai + model: glm-5.3 + endpoint: + baseUrl: https://api.z.ai/v1 + apiKeyEnv: ZAI_API_KEY +` + if conflict { + metadata = ` default: deepseek-v4-pro + models: + - id: deepseek-v4-pro + label: DeepSeek + litellm: {provider: deepseek, model: deepseek-v4-pro} + apiKeyEnv: DEEPSEEK_API_KEY +` + } + legacy := filepath.Join(root, "legacy", "thothii-installation.yaml") + if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { + t.Fatal(err) + } + source := "profile: local\nprojectDirectory: " + project + "\nenvFile: " + env + + "\nmetadataGeneration:\n" + metadata + + "authentication:\n configDirectory: " + auth + "\n" + if err := os.WriteFile(legacy, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + models := `{"providers":{"zai":{"baseUrl":"https://api.z.ai/v1","api":"openai-completions","apiKey":"$ZAI_API_KEY","models":[{"id":"glm-5.3","name":"GLM 5.3","reasoning":true,"contextWindow":200000,"maxTokens":131072}]}}}` + settings := `{"defaultProjectTrust":"always","enabledModels":["deepseek/deepseek-v4-pro","zai/glm-5.3"]}` + if err := os.WriteFile(filepath.Join(project, "deploy", "pi", "models.json"), []byte(models), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "deploy", "pi", "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + return migrationTestFixture{legacy: legacy, output: filepath.Join(root, "candidate", "thothii-installation.yaml")} +} diff --git a/tools/tht/internal/modelprojection/projection.go b/tools/tht/internal/modelprojection/projection.go new file mode 100644 index 00000000..9bd6874e --- /dev/null +++ b/tools/tht/internal/modelprojection/projection.go @@ -0,0 +1,266 @@ +// Package modelprojection renders disposable runtime adapters from the installation model catalog. +package modelprojection + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + + "github.com/aritmolab/thothii/tools/tht/internal/config" +) + +const ( + CatalogFile = "catalog.json" + PiModelsFile = "pi/models.json" + PiSettingsFile = "pi/settings.json" + ComposeFile = "compose.models.yaml" +) + +type runtimeCatalog struct { + SchemaVersion int `json:"schemaVersion"` + DefaultSession string `json:"defaultSession"` + DefaultMetadataGeneration string `json:"defaultMetadataGeneration,omitempty"` + Embedding config.ModelCatalogEmbedding `json:"embedding"` + Models []runtimeCatalogModel `json:"models"` +} + +type runtimeCatalogModel struct { + ID string `json:"id"` + Provider string `json:"provider"` + Model string `json:"model"` + Label string `json:"label"` + UpstreamModel string `json:"upstreamModel"` + Endpoint *config.ModelEndpoint `json:"endpoint,omitempty"` + Authentication config.ModelAuthentication `json:"authentication"` + SessionAdapter *config.ModelSessionAdapter `json:"sessionAdapter,omitempty"` + MetadataAdapter *config.ModelMetadataAdapter `json:"metadataAdapter,omitempty"` + Session *config.SessionModel `json:"session,omitempty"` + MetadataGeneration *config.MetadataGenerationModel `json:"metadataGeneration,omitempty"` +} + +type piModels struct { + Providers map[string]piProvider `json:"providers"` +} + +type piProvider struct { + BaseURL string `json:"baseUrl"` + API string `json:"api"` + APIKey string `json:"apiKey"` + Models []piModel `json:"models"` +} + +type piModel struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input,omitempty"` + Cost *config.ModelCost `json:"cost,omitempty"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + Compat *config.ModelCompatibility `json:"compat,omitempty"` +} + +type piSettings struct { + DefaultProjectTrust string `json:"defaultProjectTrust"` + EnabledModels []string `json:"enabledModels"` +} + +// Render builds every projection in memory so no partial candidate can be published. +func Render(installation config.Installation) (map[string][]byte, error) { + models := installation.ModelCatalog.RuntimeModels() + runtimeModels := make([]runtimeCatalogModel, 0, len(models)) + customProviders := make(map[string]piProvider) + enabled := make([]string, 0) + for _, model := range models { + runtimeModels = append(runtimeModels, runtimeCatalogModel{ + ID: model.ID, Provider: model.Provider, Model: model.Model, Label: model.Label, + UpstreamModel: model.UpstreamModel, Endpoint: model.Endpoint, + Authentication: model.Authentication, SessionAdapter: model.SessionAdapter, + MetadataAdapter: model.MetadataAdapter, Session: model.Session, + MetadataGeneration: model.MetadataGeneration, + }) + if model.Session == nil { + continue + } + enabled = append(enabled, model.ID) + if model.SessionAdapter == nil || model.SessionAdapter.Mode != "openai_compatible" { + continue + } + provider := customProviders[model.Provider] + provider.BaseURL = model.Endpoint.BaseURL + provider.API = "openai-completions" + if model.Authentication.Mode == "secret_env" { + provider.APIKey = "$" + model.Authentication.APIKeyEnv + } else { + provider.APIKey = "local" + } + provider.Models = append(provider.Models, piModel{ + ID: model.UpstreamModel, Name: model.Label, Reasoning: model.Session.Reasoning, + Input: model.Session.Input, Cost: model.Session.Cost, + ContextWindow: model.Session.ContextWindow, MaxTokens: model.Session.MaxTokens, + Compat: model.Session.Compatibility, + }) + customProviders[model.Provider] = provider + } + for id, provider := range customProviders { + sort.Slice(provider.Models, func(left, right int) bool { return provider.Models[left].ID < provider.Models[right].ID }) + customProviders[id] = provider + } + sort.Strings(enabled) + + catalogBytes, err := marshalJSON(runtimeCatalog{ + SchemaVersion: 1, DefaultSession: installation.ModelCatalog.Defaults.Session, + DefaultMetadataGeneration: installation.ModelCatalog.Defaults.MetadataGeneration, + Embedding: installation.ModelCatalog.Embedding, Models: runtimeModels, + }) + if err != nil { + return nil, fmt.Errorf("render runtime model catalog: %w", err) + } + piModelsBytes, err := marshalJSON(piModels{Providers: customProviders}) + if err != nil { + return nil, fmt.Errorf("render Pi model projection: %w", err) + } + piSettingsBytes, err := marshalJSON(piSettings{DefaultProjectTrust: "always", EnabledModels: enabled}) + if err != nil { + return nil, fmt.Errorf("render Pi settings projection: %w", err) + } + fingerprint := sha256.Sum256(bytes.Join([][]byte{catalogBytes, piModelsBytes, piSettingsBytes}, nil)) + composeBytes := renderCompose(installation, fmt.Sprintf("sha256:%x", fingerprint)) + return map[string][]byte{ + CatalogFile: catalogBytes, PiModelsFile: piModelsBytes, + PiSettingsFile: piSettingsBytes, ComposeFile: composeBytes, + }, nil +} + +// Generate atomically replaces each generated adapter after the complete candidate has rendered. +func Generate(installation config.Installation) error { + artifacts, err := Render(installation) + if err != nil { + return err + } + paths := sortedArtifactPaths(artifacts) + for _, relative := range paths { + destination := filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(relative)) + // The generated catalog contains references to secret environment variable names, never + // secret values. Core runs as an unprivileged container user and must be able to traverse + // the bind-mounted host directories and read the projections. + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("create model projection directory: %w", err) + } + if err := os.Chmod(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("protect model projection directory: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(destination), ".projection-*") + if err != nil { + return fmt.Errorf("create model projection candidate: %w", err) + } + temporaryName := temporary.Name() + published := false + defer func() { + if !published { + _ = os.Remove(temporaryName) + } + }() + if err := temporary.Chmod(0o644); err == nil { + _, err = temporary.Write(artifacts[relative]) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("write model projection candidate: %w", err) + } + if err := os.Rename(temporaryName, destination); err != nil { + return fmt.Errorf("publish model projection: %w", err) + } + published = true + } + return nil +} + +// Check returns relative artifact names whose current bytes differ from the catalog projection. +func Check(installation config.Installation) ([]string, error) { + artifacts, err := Render(installation) + if err != nil { + return nil, err + } + drift := make([]string, 0) + for _, relative := range sortedArtifactPaths(artifacts) { + actual, readErr := os.ReadFile(filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(relative))) + if readErr != nil || !bytes.Equal(actual, artifacts[relative]) { + drift = append(drift, relative) + } + } + return drift, nil +} + +func marshalJSON(value any) ([]byte, error) { + contents, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + return append(contents, '\n'), nil +} + +func renderCompose(installation config.Installation, fingerprint string) []byte { + embedding := installation.ModelCatalog.Embedding + return []byte(fmt.Sprintf(`services: + core: + environment: + THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json + THT_MODEL_CATALOG_REVISION: %s + THT_DEFAULT_SESSION_MODEL: %s + THT_INTERNAL_EMBEDDING_ID: %s + THT_INTERNAL_EMBEDDING_MODEL: %s + THT_INTERNAL_EMBEDDING_DIMENSIONS: %s + volumes: + - type: bind + source: %s + target: /run/thothii-model-catalog/catalog.json + read_only: true + - type: bind + source: %s + target: /home/thoth/.pi/agent/models.json + read_only: true + - type: bind + source: %s + target: /home/thoth/.pi/agent/settings.json + read_only: true + workspace-maintenance: + environment: + THT_INTERNAL_EMBEDDING_ID: %s + THT_INTERNAL_EMBEDDING_MODEL: %s + THT_INTERNAL_EMBEDDING_DIMENSIONS: %s + embedding-model-init: + environment: + OLLAMA_MODEL: %s +`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Session), + strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)), + strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()), + strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID), + strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)), + strconv.Quote(embeddingModelName(embedding.ID)))) +} + +func embeddingModelName(canonical string) string { + for index := 0; index < len(canonical); index++ { + if canonical[index] == '/' { + return canonical[index+1:] + } + } + return canonical +} + +func sortedArtifactPaths(artifacts map[string][]byte) []string { + paths := make([]string, 0, len(artifacts)) + for path := range artifacts { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} diff --git a/tools/tht/internal/modelprojection/projection_test.go b/tools/tht/internal/modelprojection/projection_test.go new file mode 100644 index 00000000..3aab0a32 --- /dev/null +++ b/tools/tht/internal/modelprojection/projection_test.go @@ -0,0 +1,91 @@ +package modelprojection + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" +) + +func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T) { + installation := projectionFixture(t) + + first, err := Render(installation) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + second, err := Render(installation) + if err != nil { + t.Fatalf("Render() second error = %v", err) + } + if len(first) != 4 { + t.Fatalf("artifact count = %d, want 4", len(first)) + } + for path, contents := range first { + if !bytes.Equal(contents, second[path]) { + t.Fatalf("artifact %q is not deterministic", path) + } + } + + catalog := string(first[CatalogFile]) + for _, expected := range []string{`"id": "local/qwen"`, `"defaultSession": "zai/glm-5.3"`, `"embedding"`} { + if !strings.Contains(catalog, expected) { + t.Fatalf("catalog projection missing %s:\n%s", expected, catalog) + } + } + piModels := string(first[PiModelsFile]) + if !strings.Contains(piModels, `"zai"`) || strings.Contains(piModels, `"deepseek"`) { + t.Fatalf("Pi models projection must contain only custom providers:\n%s", piModels) + } + settings := string(first[PiSettingsFile]) + if !strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen"`) { + t.Fatalf("Pi settings projection missing enabled session models:\n%s", settings) + } + compose := string(first[ComposeFile]) + for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} { + if !strings.Contains(compose, expected) { + t.Fatalf("Compose projection missing %q:\n%s", expected, compose) + } + } +} + +func TestGeneratePublishesAllArtifactsAndCheckDetectsDrift(t *testing.T) { + installation := projectionFixture(t) + if err := Generate(installation); err != nil { + t.Fatalf("Generate() error = %v", err) + } + for _, path := range []string{installation.GeneratedModelCatalogPath(), installation.GeneratedPiModelsPath(), installation.GeneratedPiSettingsPath(), installation.ModelProjectionComposePath()} { + if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() { + t.Fatalf("generated artifact %q: info=%v err=%v", path, info, err) + } + } + if drift, err := Check(installation); err != nil || len(drift) != 0 { + t.Fatalf("Check() = %v, %v; want no drift", drift, err) + } + if err := os.WriteFile(installation.GeneratedPiSettingsPath(), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if drift, err := Check(installation); err != nil || len(drift) != 1 || drift[0] != PiSettingsFile { + t.Fatalf("Check() = %v, %v; want [%s]", drift, err, PiSettingsFile) + } +} + +func projectionFixture(t *testing.T) config.Installation { + t.Helper() + root := t.TempDir() + return config.Installation{ + Path: filepath.Join(root, "thothii-installation.yaml"), + ModelCatalog: config.ModelCatalog{ + Defaults: config.ModelCatalogDefaults{Session: "zai/glm-5.3", MetadataGeneration: "local/qwen"}, + Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024}, + Providers: map[string]config.ModelProvider{ + "deepseek": {Authentication: config.ModelAuthentication{Mode: "pi_auth"}, Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, Models: map[string]config.CatalogModel{"deepseek-v4-pro": {Session: &config.SessionModel{}}}}, + "zai": {Endpoint: &config.ModelEndpoint{BaseURL: "https://api.z.ai/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, Models: map[string]config.CatalogModel{"glm-5.3": {Label: "GLM-5.3", Session: &config.SessionModel{Reasoning: true, ContextWindow: 200000, MaxTokens: 131072}}}}, + "local": {Endpoint: &config.ModelEndpoint{BaseURL: "http://ollama:11434/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"qwen": {Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}, MetadataGeneration: &config.MetadataGenerationModel{DisableThinking: true}}}}, + }, + }, + } +} diff --git a/tools/tht/internal/pi/commands.go b/tools/tht/internal/pi/commands.go index 107c6f5b..b685a2c9 100644 --- a/tools/tht/internal/pi/commands.go +++ b/tools/tht/internal/pi/commands.go @@ -1,215 +1,17 @@ package pi import ( - "bytes" "context" "crypto/sha256" - "encoding/base64" "encoding/json" "errors" "fmt" "io" - "regexp" "strings" "github.com/aritmolab/thothii/tools/tht/internal/compose" ) -var choicePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) - -type Defaults struct { - Provider string `json:"provider"` - Model string `json:"model"` - Thinking string `json:"thinking"` -} - -type ModelOption struct { - Provider string `json:"provider"` - ID string `json:"id"` -} - -type piOptions struct { - Providers []string `json:"providers"` - Models []ModelOption `json:"models"` - Reasoning []string `json:"reasoning"` -} - -type settingsFileSnapshot struct { - Exists bool `json:"exists"` - RawBase64 string `json:"rawBase64"` -} - -// Configure changes the backend's real installation settings through a core-side helper. It -// deliberately has no secret or endpoint input: external endpoints remain Compose-owned. -func Configure(ctx context.Context, runner Runner, value Defaults) error { - if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) { - return errors.New("provider and model must be supported identifiers") - } - before, err := renderedCore(ctx, runner) - if err != nil { - return err - } - options, err := configurationOptions(ctx, runner) - if err != nil { - return err - } - found := false - for _, model := range options.Models { - if model.Provider == value.Provider && model.ID == value.Model { - found = true - } - } - if !found { - return errors.New("provider/model is not in Pi options") - } - thinkingFound := false - for _, reasoning := range options.Reasoning { - if reasoning == value.Thinking { - thinkingFound = true - } - } - if !thinkingFound { - return errors.New("thinking is not in Pi options") - } - old, err := captureSettingsFile(ctx, runner) - if err != nil { - return err - } - oldEffective, err := readEffectiveSettings(ctx, runner) - if err != nil { - return err - } - restore := func(cause error) error { - if restoreErr := restoreSettingsFile(context.Background(), runner, old); restoreErr != nil { - return fmt.Errorf("%w; previous Pi settings restoration could not be verified: %w", cause, restoreErr) - } - restoredEffective, restoreErr := readEffectiveSettings(context.Background(), runner) - if restoreErr != nil || !bytes.Equal(restoredEffective, oldEffective) { - return fmt.Errorf("%w; previous effective Pi settings could not be verified: recovery required", cause) - } - return cause - } - result, err := writeDefaults(ctx, runner, value) - if err != nil { - return restore(commandError("Pi installation settings write", result, err)) - } - settings, err := readEffectiveSettings(ctx, runner) - if err != nil { - return restore(err) - } - var saved Defaults - if json.Unmarshal(settings, &saved) != nil || saved.Provider != value.Provider || saved.Model != value.Model || saved.Thinking != value.Thinking { - return restore(errors.New("Pi installation settings read-back did not match requested provider, model, and thinking")) - } - after, err := renderedCore(ctx, runner) - if err != nil { - return restore(err) - } - if before.ConfigurationSHA != after.ConfigurationSHA { - return restore(errors.New("external endpoint configuration changed while configuring Pi")) - } - return nil -} - -func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) { - options, err := configurationOptions(ctx, runner) - if err != nil { - return nil, err - } - return options.Models, nil -} - -func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) { - result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/operator-command.js", "pi-options") - if err != nil { - return piOptions{}, commandError("Pi options check", result, err) - } - var payload piOptions - if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 { - return piOptions{}, errors.New("Pi options response is invalid or empty") - } - providers := make(map[string]bool, len(payload.Providers)) - for _, provider := range payload.Providers { - if !choicePattern.MatchString(provider) || providers[provider] { - return piOptions{}, errors.New("Pi options response contains an invalid provider") - } - providers[provider] = true - } - models := make(map[string]bool, len(payload.Models)) - for _, option := range payload.Models { - key := option.Provider + "\x00" + option.ID - if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] { - return piOptions{}, errors.New("Pi options response contains an invalid provider/model") - } - models[key] = true - } - reasoning := make(map[string]bool, len(payload.Reasoning)) - for _, value := range payload.Reasoning { - if (value != "low" && value != "medium" && value != "high") || reasoning[value] { - return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice") - } - reasoning[value] = true - } - return payload, nil -} - -func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) { - return runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", value.Provider, "--model", value.Model, "--thinking", value.Thinking) -} - -func captureSettingsFile(ctx context.Context, runner Runner) (settingsFileSnapshot, error) { - result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--snapshot") - if err != nil { - return settingsFileSnapshot{}, commandError("Pi installation settings snapshot", result, err) - } - var snapshot settingsFileSnapshot - if json.Unmarshal([]byte(result.Stdout), &snapshot) != nil { - return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid") - } - raw, decodeErr := base64.StdEncoding.DecodeString(snapshot.RawBase64) - if decodeErr != nil || base64.StdEncoding.EncodeToString(raw) != snapshot.RawBase64 || (!snapshot.Exists && len(raw) != 0) { - return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid") - } - return snapshot, nil -} - -func restoreSettingsFile(ctx context.Context, runner Runner, snapshot settingsFileSnapshot) error { - payload, err := json.Marshal(snapshot) - if err != nil { - return errors.New("Pi installation settings snapshot could not be encoded") - } - args := []string{"compose", "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--restore"} - result, restoreErr := runner.Run(ctx, args, bytes.NewReader(payload)) - verified, verifyErr := captureSettingsFile(ctx, runner) - if restoreErr != nil { - cause := commandError("Pi installation settings restore", result, restoreErr) - if verifyErr == nil && verified == snapshot { - return recoveryRequired("previous Pi settings bytes were restored but durability was not acknowledged", cause) - } - return cause - } - if verifyErr == nil && verified == snapshot { - return nil - } - return errors.New("Pi installation settings restore did not reproduce the exact prior file state") -} - -func readEffectiveSettings(ctx context.Context, runner Runner) ([]byte, error) { - result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/operator-command.js", "effective-settings") - if err != nil { - return nil, commandError("Pi installation settings read-back", result, err) - } - var settings map[string]json.RawMessage - if json.Unmarshal([]byte(result.Stdout), &settings) != nil || settings == nil { - return nil, errors.New("Pi installation settings read-back is invalid") - } - canonical, err := json.Marshal(settings) - if err != nil { - return nil, errors.New("Pi installation settings read-back could not be normalized") - } - return canonical, nil -} - // Runner is the narrow, shell-free command boundary shared with tht. type Runner interface { Run(context.Context, []string, io.Reader) (compose.Result, error) diff --git a/tools/tht/internal/pi/commands_test.go b/tools/tht/internal/pi/commands_test.go index e4644748..039a7f18 100644 --- a/tools/tht/internal/pi/commands_test.go +++ b/tools/tht/internal/pi/commands_test.go @@ -2,14 +2,8 @@ package pi import ( "context" - "encoding/base64" - "encoding/json" - "errors" - "io" "strings" "testing" - - "github.com/aritmolab/thothii/tools/tht/internal/compose" ) func TestDoctorRequiresExternalEndpointAuthPiStateAndHealth(t *testing.T) { @@ -41,206 +35,6 @@ func TestDoctorRejectsActualEnvironmentAndImageLabelVersionMismatches(t *testing } } -func TestConfigureRestoresAndVerifiesOldSettingsAfterEveryPostSnapshotFailure(t *testing.T) { - for _, failure := range []string{"helper", "readback", "digest"} { - t.Run(failure, func(t *testing.T) { - old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} - raw, _ := json.Marshal(old) - fake := &configureRunner{failure: failure, settings: old, settingsExist: true, settingsRaw: raw} - err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) - if err == nil { - t.Fatal("Configure() error = nil, want injected failure") - } - if fake.settings != (Defaults{Provider: "old", Model: "old-model", Thinking: "low"}) { - t.Fatalf("settings after failure = %#v, want old snapshot", fake.settings) - } - if !fake.settingsExist || string(fake.settingsRaw) != string(raw) { - t.Fatalf("settings raw snapshot after failure = exists:%t raw:%q, want %q", fake.settingsExist, fake.settingsRaw, raw) - } - }) - } -} - -func TestSettingsRestoreDoesNotMaskExplicitDurabilityFailureWithMatchingReadback(t *testing.T) { - old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} - raw, _ := json.Marshal(old) - fake := &configureRunner{ - failure: "restore-durability", - settings: Defaults{Provider: "new", Model: "new-model", Thinking: "high"}, - settingsExist: true, - settingsRaw: []byte(`{"provider":"new","model":"new-model","thinking":"high"}`), - } - snapshot := settingsFileSnapshot{Exists: true, RawBase64: base64.StdEncoding.EncodeToString(raw)} - - err := restoreSettingsFile(context.Background(), fake, snapshot) - - var recovery interface{ RecoveryRequired() bool } - if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() { - t.Fatalf("restore error = %v; want typed recovery-required result", err) - } - if !fake.settingsExist || string(fake.settingsRaw) != string(raw) || fake.settings != old { - t.Fatalf("restored state = exists:%t raw:%q value:%#v; want exact old bytes", fake.settingsExist, fake.settingsRaw, fake.settings) - } -} - -func TestConfigurePreservesTypedRecoveryRequiredErrorFromSettingsRestore(t *testing.T) { - old := Defaults{Provider: "old", Model: "old-model", Thinking: "low"} - raw, _ := json.Marshal(old) - fake := &configureRunner{ - failure: "helper", - restoreDurabilityFailure: true, - settings: old, - settingsExist: true, - settingsRaw: raw, - } - - err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) - - var recovery interface{ RecoveryRequired() bool } - if err == nil || !errors.As(err, &recovery) || !recovery.RecoveryRequired() { - t.Fatalf("Configure() error = %v; want typed recovery-required result", err) - } -} - -type configureRunner struct { - calls []string - failure string - restoreDurabilityFailure bool - settings Defaults - settingsExist bool - settingsRaw []byte - settingsReads int - configReads int - writes int -} - -func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader) (compose.Result, error) { - call := strings.Join(args, " ") - f.calls = append(f.calls, call) - switch { - case strings.Contains(call, "config --format json"): - f.configReads++ - endpoint := "https://llm.example.invalid" - if f.failure == "digest" && f.configReads > 1 { - endpoint = "https://drift.example.invalid" - } - return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil - case strings.Contains(call, "operator-command.js pi-options"): - return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil - case strings.Contains(call, "settings-cli.js --snapshot"): - raw := f.settingsRaw - payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)} - contents, _ := json.Marshal(payload) - return compose.Result{Stdout: string(contents)}, nil - case strings.Contains(call, "settings-cli.js --restore"): - var payload struct { - Exists bool `json:"exists"` - RawBase64 string `json:"rawBase64"` - } - contents, _ := io.ReadAll(stdin) - if json.Unmarshal(contents, &payload) != nil { - return compose.Result{ExitCode: 2}, errors.New("invalid restore payload") - } - f.settingsExist = payload.Exists - f.settingsRaw, _ = base64.StdEncoding.DecodeString(payload.RawBase64) - f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"} - if payload.Exists { - _ = json.Unmarshal(f.settingsRaw, &f.settings) - } - if f.failure == "restore-durability" || f.restoreDurabilityFailure { - return compose.Result{ExitCode: 2}, errors.New("injected post-rename directory fsync failure") - } - return compose.Result{}, nil - case strings.Contains(call, "settings-cli.js"): - if strings.Contains(call, "--provider new") { - f.settings = Defaults{Provider: "new", Model: "new-model", Thinking: "high"} - f.settingsExist = true - f.settingsRaw, _ = json.MarshalIndent(f.settings, "", " ") - f.writes++ - if f.failure == "helper" { - return compose.Result{ExitCode: 17}, errors.New("injected helper failure") - } - } else { - f.settings = Defaults{Provider: "old", Model: "old-model", Thinking: "low"} - f.settingsExist = true - f.settingsRaw, _ = json.Marshal(f.settings) - } - return compose.Result{}, nil - case strings.Contains(call, "operator-command.js effective-settings"): - f.settingsReads++ - if f.failure == "readback" && f.settings.Provider == "new" { - return compose.Result{Stdout: `{}`}, nil - } - if !f.settingsExist { - return compose.Result{Stdout: `{"provider":"old","model":"old-model","thinking":"low"}`}, nil - } - contents, _ := json.Marshal(f.settings) - return compose.Result{Stdout: string(contents)}, nil - default: - return compose.Result{}, nil - } -} - -func TestConfigureAllowsAFirstRunWithoutAnExistingSettingsFile(t *testing.T) { - fake := &configureRunner{} - if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}); err != nil { - t.Fatalf("Configure() clean install error = %v", err) - } - if !fake.settingsExist || fake.writes != 1 || fake.settings.Provider != "new" { - t.Fatalf("clean settings = exists:%t writes:%d value:%#v", fake.settingsExist, fake.writes, fake.settings) - } -} - -func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T) { - for _, prior := range []struct { - name string - exists bool - raw []byte - }{ - {name: "absent"}, - {name: "empty", exists: true, raw: []byte{}}, - {name: "exact raw", exists: true, raw: []byte("{\n \"workspace\": \"kept\",\n \"provider\": \"old\",\n \"model\": \"old-model\",\n \"thinking\": \"low\"\n}\n")}, - } { - t.Run(prior.name, func(t *testing.T) { - fake := &configureRunner{failure: "digest", settingsExist: prior.exists, settingsRaw: append([]byte{}, prior.raw...), settings: Defaults{Provider: "old", Model: "old-model", Thinking: "low"}} - err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}) - if err == nil { - t.Fatal("Configure() error = nil, want compensated digest failure") - } - if fake.writes != 1 { - t.Fatalf("settings writes = %d, want selected values written before compensation", fake.writes) - } - if fake.settingsExist != prior.exists || string(fake.settingsRaw) != string(prior.raw) { - t.Fatalf("restored exists/raw = %t/%q, want %t/%q", fake.settingsExist, fake.settingsRaw, prior.exists, prior.raw) - } - if fake.settingsReads < 3 { - t.Fatalf("settings reads = %d, want prior effective state, requested readback, and restored default verification", fake.settingsReads) - } - }) - } -} - -// Catches tht reading the legacy public model route instead of the admin-only closed Pi -// Management choices before it writes shared installation defaults. -func TestConfigureUsesScopedCoreCommandWithoutMintingAnHTTPIdentity(t *testing.T) { - fake := &configureRunner{ - settings: Defaults{Provider: "old", Model: "old-model", Thinking: "low"}, - settingsExist: true, - settingsRaw: []byte(`{"provider":"old","model":"old-model","thinking":"low"}`), - } - if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "new-model", Thinking: "high"}); err != nil { - t.Fatal(err) - } - assertCalled(t, fake.calls, "operator-command.js pi-options") - assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider new --model new-model --thinking high") - if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "x-thoth-principal") || strings.Contains(got, "x-thoth-is-admin") || strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") { - t.Fatalf("commands=%q", got) - } - if err := Configure(context.Background(), fake, Defaults{Provider: "new", Model: "unknown", Thinking: "medium"}); err == nil { - t.Fatal("expected unknown model rejection") - } -} - // Catches a smoke check that composes health/models/settings itself and drifts from the dedicated // backend contract, rather than retaining only the independent in-container version signal. func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) { @@ -275,13 +69,3 @@ func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) { } assertCalled(t, fake.calls, "pi --version") } - -// Catches tht accepting a reasoning level that the backend did not publish as a closed -// installation option, which would bypass the Pi Management validation surface. -func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) { - fake := newFakeRunner() - fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}` - if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") { - t.Fatalf("Configure() error = %v, want closed reasoning rejection", err) - } -} diff --git a/tools/tht/internal/service/service.go b/tools/tht/internal/service/service.go index d067ba9f..3ee42d63 100644 --- a/tools/tht/internal/service/service.go +++ b/tools/tht/internal/service/service.go @@ -13,12 +13,14 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" ) const healthTimeout = 5 * time.Minute var healthPollInterval = time.Second var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady +var generateModelProjections = modelprojection.Generate // HealthFailure identifies the last non-ready service after a bounded health wait. type HealthFailure struct { @@ -46,6 +48,9 @@ func Start(ctx context.Context, installation config.Installation, runner compose if err := requireRuntimeAuthProjectionReady(installation); err != nil { return errors.New("runtime authentication projection is unavailable") } + if err := generateModelProjections(installation); err != nil { + return fmt.Errorf("model runtime projection: %w", err) + } if build { if err := runCompose(ctx, installation, runner, "build"); err != nil { return fmt.Errorf("image build: %w", err) diff --git a/tools/tht/internal/service/service_test.go b/tools/tht/internal/service/service_test.go index a3917456..dcee4b65 100644 --- a/tools/tht/internal/service/service_test.go +++ b/tools/tht/internal/service/service_test.go @@ -13,6 +13,7 @@ import ( // Catches --build being ignored, which would run stale images after a checkout update. func TestStartBuildsBeforeStartingAndCheckingHealth(t *testing.T) { + stubModelProjection(t, nil) runner := &recordingRunner{} installation := testInstallation() @@ -26,6 +27,7 @@ func TestStartBuildsBeforeStartingAndCheckingHealth(t *testing.T) { // Catches a normal start unnecessarily rebuilding images. func TestStartSkipsBuildUnlessRequested(t *testing.T) { + stubModelProjection(t, nil) runner := &recordingRunner{} installation := testInstallation() @@ -38,6 +40,7 @@ func TestStartSkipsBuildUnlessRequested(t *testing.T) { } func TestStartRefusesProjectedAuthenticationBeforeComposeWhenNotReady(t *testing.T) { + stubModelProjection(t, nil) for _, state := range []string{"missing", "blocked", "divergent"} { t.Run(state, func(t *testing.T) { previous := requireRuntimeAuthProjectionReady @@ -61,6 +64,26 @@ func TestStartRefusesProjectedAuthenticationBeforeComposeWhenNotReady(t *testing } } +func TestStartRefusesProjectionFailureBeforeCompose(t *testing.T) { + stubModelProjection(t, errors.New("synthetic projection failure")) + runner := &recordingRunner{} + + err := Start(context.Background(), testInstallation(), runner, false) + if err == nil || !strings.Contains(err.Error(), "model runtime projection") { + t.Fatalf("Start() error = %v", err) + } + if len(runner.stages) != 0 { + t.Fatalf("Start() reached Compose: %v", runner.stages) + } +} + +func stubModelProjection(t *testing.T, result error) { + t.Helper() + previous := generateModelProjections + generateModelProjections = func(config.Installation) error { return result } + t.Cleanup(func() { generateModelProjections = previous }) +} + type recordingRunner struct{ stages []string } func (r *recordingRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { diff --git a/tools/tht/internal/setup/files.go b/tools/tht/internal/setup/files.go index 3d713109..ca9384a8 100644 --- a/tools/tht/internal/setup/files.go +++ b/tools/tht/internal/setup/files.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "gopkg.in/yaml.v3" ) @@ -46,6 +47,7 @@ type answers struct { } type generatedDescriptor struct { + SchemaVersion int `yaml:"schemaVersion"` Profile string `yaml:"profile"` ProjectDirectory string `yaml:"projectDirectory"` EnvFile string `yaml:"envFile"` @@ -62,7 +64,8 @@ type generatedDescriptor struct { GID uint32 `yaml:"gid"` } `yaml:"runtimeProjection,omitempty"` } `yaml:"authentication"` - Overrides []string `yaml:"overrides"` + ModelCatalog config.ModelCatalog `yaml:"modelCatalog"` + Overrides []string `yaml:"overrides"` } // EnsureFiles writes a descriptor and non-secret environment file below deploy/. @@ -318,7 +321,7 @@ func installationDirectory(root, id string) (string, error) { } func render(root, descriptorPath string, value answers) ([]byte, []byte, error) { - descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)} + descriptor := generatedDescriptor{SchemaVersion: 2, Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName), ModelCatalog: defaultModelCatalog()} descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth") if value.profile == "server" { @@ -372,6 +375,22 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error) return descriptorBytes, []byte(strings.Join(lines, "\n") + "\n"), nil } +func defaultModelCatalog() config.ModelCatalog { + return config.ModelCatalog{ + Defaults: config.ModelCatalogDefaults{Session: "deepseek/deepseek-v4-pro"}, + Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024}, + Providers: map[string]config.ModelProvider{ + "deepseek": { + Authentication: config.ModelAuthentication{Mode: "pi_auth"}, + Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, + Models: map[string]config.CatalogModel{ + "deepseek-v4-pro": {Session: &config.SessionModel{}}, + }, + }, + }, + } +} + func dotenvValue(value string) string { return strconv.Quote(value) } func requireCompatibleOrAbsent(path string, expected []byte) error { diff --git a/tools/tht/internal/setup/run.go b/tools/tht/internal/setup/run.go index 7c3cb202..67d99f44 100644 --- a/tools/tht/internal/setup/run.go +++ b/tools/tht/internal/setup/run.go @@ -15,6 +15,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" + "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/project" "github.com/aritmolab/thothii/tools/tht/internal/service" ) @@ -60,6 +61,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R if err := configureAuthentication(ctx, installation, request, input, output); err != nil { return Result{}, err } + if err := modelprojection.Generate(installation); err != nil { + return Result{}, fmt.Errorf("setup model runtime projection: %w", err) + } if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil { return Result{}, fmt.Errorf("setup Compose configuration: %w", err) }